1 from __future__
import absolute_import
, division
, print_function
, unicode_literals
2 from functools
import partial
6 from qtpy
import QtCore
8 from qtpy
import QtWidgets
9 from qtpy
.QtCore
import Qt
10 from qtpy
.QtCore
import Signal
13 from ..editpatch
import edit_patch
14 from ..interaction
import Interaction
15 from ..models
import main
16 from ..models
import prefs
17 from ..qtutils
import get
18 from .. import actions
21 from .. import diffparse
22 from .. import gitcmds
23 from .. import gravatar
24 from .. import hotkeys
27 from .. import qtutils
28 from .text
import TextDecorator
29 from .text
import VimHintedPlainTextEdit
31 from . import imageview
34 class DiffSyntaxHighlighter(QtGui
.QSyntaxHighlighter
):
35 """Implements the diff syntax highlighting"""
40 DIFF_FILE_HEADER_STATE
= 2
45 DIFF_FILE_HEADER_START_RGX
= re
.compile(r
'diff --git a/.* b/.*')
46 DIFF_HUNK_HEADER_RGX
= re
.compile(
47 r
'(?:@@ -[0-9,]+ \+[0-9,]+ @@)|(?:@@@ (?:-[0-9,]+ ){2}\+[0-9,]+ @@@)'
49 BAD_WHITESPACE_RGX
= re
.compile(r
'\s+$')
51 def __init__(self
, context
, doc
, whitespace
=True, is_commit
=False):
52 QtGui
.QSyntaxHighlighter
.__init
__(self
, doc
)
53 self
.whitespace
= whitespace
55 self
.is_commit
= is_commit
57 QPalette
= QtGui
.QPalette
60 disabled
= palette
.color(QPalette
.Disabled
, QPalette
.Text
)
61 header
= qtutils
.rgb_hex(disabled
)
63 dark
= palette
.color(QPalette
.Base
).lightnessF() < 0.5
65 self
.color_text
= qtutils
.RGB(cfg
.color('text', '030303'))
66 self
.color_add
= qtutils
.RGB(cfg
.color('add', '77aa77' if dark
else 'd2ffe4'))
67 self
.color_remove
= qtutils
.RGB(
68 cfg
.color('remove', 'aa7777' if dark
else 'fee0e4')
70 self
.color_header
= qtutils
.RGB(cfg
.color('header', header
))
72 self
.diff_header_fmt
= qtutils
.make_format(fg
=self
.color_header
)
73 self
.bold_diff_header_fmt
= qtutils
.make_format(fg
=self
.color_header
, bold
=True)
75 self
.diff_add_fmt
= qtutils
.make_format(fg
=self
.color_text
, bg
=self
.color_add
)
76 self
.diff_remove_fmt
= qtutils
.make_format(
77 fg
=self
.color_text
, bg
=self
.color_remove
79 self
.bad_whitespace_fmt
= qtutils
.make_format(bg
=Qt
.red
)
80 self
.setCurrentBlockState(self
.INITIAL_STATE
)
82 def set_enabled(self
, enabled
):
83 self
.enabled
= enabled
85 def highlightBlock(self
, text
):
86 if not self
.enabled
or not text
:
88 # Aliases for quick local access
89 initial_state
= self
.INITIAL_STATE
90 default_state
= self
.DEFAULT_STATE
91 diff_state
= self
.DIFF_STATE
92 diffstat_state
= self
.DIFFSTAT_STATE
93 diff_file_header_state
= self
.DIFF_FILE_HEADER_STATE
94 submodule_state
= self
.SUBMODULE_STATE
95 end_state
= self
.END_STATE
97 diff_file_header_start_rgx
= self
.DIFF_FILE_HEADER_START_RGX
98 diff_hunk_header_rgx
= self
.DIFF_HUNK_HEADER_RGX
99 bad_whitespace_rgx
= self
.BAD_WHITESPACE_RGX
101 diff_header_fmt
= self
.diff_header_fmt
102 bold_diff_header_fmt
= self
.bold_diff_header_fmt
103 diff_add_fmt
= self
.diff_add_fmt
104 diff_remove_fmt
= self
.diff_remove_fmt
105 bad_whitespace_fmt
= self
.bad_whitespace_fmt
107 state
= self
.previousBlockState()
108 if state
== initial_state
:
109 if text
.startswith('Submodule '):
110 state
= submodule_state
111 elif text
.startswith('diff --git '):
112 state
= diffstat_state
114 state
= default_state
116 state
= diffstat_state
118 if state
== diffstat_state
:
119 if diff_file_header_start_rgx
.match(text
):
120 state
= diff_file_header_state
121 self
.setFormat(0, len(text
), diff_header_fmt
)
122 elif diff_hunk_header_rgx
.match(text
):
124 self
.setFormat(0, len(text
), bold_diff_header_fmt
)
127 self
.setFormat(0, i
, bold_diff_header_fmt
)
128 self
.setFormat(i
, len(text
) - i
, diff_header_fmt
)
130 self
.setFormat(0, len(text
), diff_header_fmt
)
131 elif state
== diff_file_header_state
:
132 if diff_hunk_header_rgx
.match(text
):
134 self
.setFormat(0, len(text
), bold_diff_header_fmt
)
136 self
.setFormat(0, len(text
), diff_header_fmt
)
137 elif state
== diff_state
:
138 if diff_file_header_start_rgx
.match(text
):
139 state
= diff_file_header_state
140 self
.setFormat(0, len(text
), diff_header_fmt
)
141 elif diff_hunk_header_rgx
.match(text
):
142 self
.setFormat(0, len(text
), bold_diff_header_fmt
)
143 elif text
.startswith('-'):
147 self
.setFormat(0, len(text
), diff_remove_fmt
)
148 elif text
.startswith('+'):
149 self
.setFormat(0, len(text
), diff_add_fmt
)
151 m
= bad_whitespace_rgx
.search(text
)
154 self
.setFormat(i
, len(text
) - i
, bad_whitespace_fmt
)
156 self
.setCurrentBlockState(state
)
159 # pylint: disable=too-many-ancestors
160 class DiffTextEdit(VimHintedPlainTextEdit
):
161 """A textedit for interacting with diff text"""
164 self
, context
, parent
, is_commit
=False, whitespace
=True, numbers
=False
166 VimHintedPlainTextEdit
.__init
__(self
, context
, '', parent
=parent
)
167 # Diff/patch syntax highlighter
168 self
.highlighter
= DiffSyntaxHighlighter(
169 context
, self
.document(), is_commit
=is_commit
, whitespace
=whitespace
172 self
.numbers
= DiffLineNumbers(context
, self
)
176 self
.scrollvalue
= None
178 self
.copy_diff_action
= qtutils
.add_action(
184 self
.copy_diff_action
.setIcon(icons
.copy())
185 self
.copy_diff_action
.setEnabled(False)
186 self
.menu_actions
.append(self
.copy_diff_action
)
188 # pylint: disable=no-member
189 self
.cursorPositionChanged
.connect(self
._cursor
_changed
, Qt
.QueuedConnection
)
190 self
.selectionChanged
.connect(self
._selection
_changed
, Qt
.QueuedConnection
)
192 def setFont(self
, font
):
193 """Override setFont() so that we can use a custom "block" cursor"""
194 super(DiffTextEdit
, self
).setFont(font
)
195 if prefs
.block_cursor(self
.context
):
196 metrics
= QtGui
.QFontMetrics(font
)
197 width
= metrics
.width('M')
198 self
.setCursorWidth(width
)
200 def _cursor_changed(self
):
201 """Update the line number display when the cursor changes"""
202 line_number
= max(0, self
.textCursor().blockNumber())
204 self
.numbers
.set_highlighted(line_number
)
206 def _selection_changed(self
):
207 """Respond to selection changes"""
208 selected
= bool(self
.selected_text())
209 self
.copy_diff_action
.setEnabled(selected
)
211 def resizeEvent(self
, event
):
212 super(DiffTextEdit
, self
).resizeEvent(event
)
214 self
.numbers
.refresh_size()
216 def save_scrollbar(self
):
217 """Save the scrollbar value, but only on the first call"""
218 if self
.scrollvalue
is None:
219 scrollbar
= self
.verticalScrollBar()
221 scrollvalue
= get(scrollbar
)
224 self
.scrollvalue
= scrollvalue
226 def restore_scrollbar(self
):
227 """Restore the scrollbar and clear state"""
228 scrollbar
= self
.verticalScrollBar()
229 scrollvalue
= self
.scrollvalue
230 if scrollbar
and scrollvalue
is not None:
231 scrollbar
.setValue(scrollvalue
)
232 self
.scrollvalue
= None
234 def set_loading_message(self
):
235 """Add a pending loading message in the diff view"""
236 self
.hint
.set_value('+++ ' + N_('Loading...'))
239 def set_diff(self
, diff
):
240 """Set the diff text, but save the scrollbar"""
241 diff
= diff
.rstrip('\n') # diffs include two empty newlines
242 self
.save_scrollbar()
244 self
.hint
.set_value('')
246 self
.numbers
.set_diff(diff
)
249 self
.restore_scrollbar()
251 def selected_diff_stripped(self
):
252 """Return the selected diff stripped of any diff characters"""
253 sep
, selection
= self
.selected_text_lines()
254 return sep
.join(_strip_diff(line
) for line
in selection
)
257 """Copy the selected diff text stripped of any diff prefix characters"""
258 text
= self
.selected_diff_stripped()
259 qtutils
.set_clipboard(text
)
261 def selected_lines(self
):
262 """Return selected lines"""
263 cursor
= self
.textCursor()
264 selection_start
= cursor
.selectionStart()
265 selection_end
= max(selection_start
, cursor
.selectionEnd() - 1)
272 for line_idx
, line
in enumerate(get(self
, default
='').splitlines()):
273 line_end
= line_start
+ len(line
)
274 if line_start
<= selection_start
<= line_end
:
275 first_line_idx
= line_idx
276 if line_start
<= selection_end
<= line_end
:
277 last_line_idx
= line_idx
279 line_start
= line_end
+ 1
281 if first_line_idx
== -1:
282 first_line_idx
= line_idx
284 if last_line_idx
== -1:
285 last_line_idx
= line_idx
287 return first_line_idx
, last_line_idx
289 def selected_text_lines(self
):
290 """Return selected lines and the CRLF / LF separator"""
291 first_line_idx
, last_line_idx
= self
.selected_lines()
292 text
= get(self
, default
='')
295 for line_idx
, line
in enumerate(text
.split(sep
)):
296 if first_line_idx
<= line_idx
<= last_line_idx
:
302 """Return either CRLF or LF based on the content"""
310 def _strip_diff(value
):
311 """Remove +/-/<space> from a selection"""
312 if value
.startswith(('+', '-', ' ')):
317 class DiffLineNumbers(TextDecorator
):
318 def __init__(self
, context
, parent
):
319 TextDecorator
.__init
__(self
, parent
)
320 self
.highlight_line
= -1
322 self
.parser
= diffparse
.DiffLines()
323 self
.formatter
= diffparse
.FormatDigits()
325 self
.setFont(qtutils
.diff_font(context
))
326 self
._char
_width
= self
.fontMetrics().width('0')
328 QPalette
= QtGui
.QPalette
329 self
._palette
= palette
= self
.palette()
330 self
._base
= palette
.color(QtGui
.QPalette
.Base
)
331 self
._highlight
= palette
.color(QPalette
.Highlight
)
332 self
._highlight
.setAlphaF(0.3)
333 self
._highlight
_text
= palette
.color(QPalette
.HighlightedText
)
334 self
._window
= palette
.color(QPalette
.Window
)
335 self
._disabled
= palette
.color(QPalette
.Disabled
, QPalette
.Text
)
337 def set_diff(self
, diff
):
338 self
.lines
= self
.parser
.parse(diff
)
339 self
.formatter
.set_digits(self
.parser
.digits())
341 def width_hint(self
):
342 if not self
.isVisible():
348 extra
= 3 # one space in-between, one space after
351 extra
= 2 # one space in-between, one space after
353 digits
= parser
.digits() * columns
355 return defs
.margin
+ (self
._char
_width
* (digits
+ extra
))
357 def set_highlighted(self
, line_number
):
358 """Set the line to highlight"""
359 self
.highlight_line
= line_number
361 def current_line(self
):
363 if lines
and self
.highlight_line
>= 0:
364 # Find the next valid line
365 for i
in range(self
.highlight_line
, len(lines
)):
366 # take the "new" line number: last value in tuple
367 line_number
= lines
[i
][-1]
371 # Find the previous valid line
372 for i
in range(self
.highlight_line
- 1, -1, -1):
373 # take the "new" line number: last value in tuple
375 line_number
= lines
[i
][-1]
380 def paintEvent(self
, event
):
381 """Paint the line number"""
385 painter
= QtGui
.QPainter(self
)
386 painter
.fillRect(event
.rect(), self
._base
)
389 content_offset
= editor
.contentOffset()
390 block
= editor
.firstVisibleBlock()
392 text_width
= width
- (defs
.margin
* 2)
393 text_flags
= Qt
.AlignRight | Qt
.AlignVCenter
394 event_rect_bottom
= event
.rect().bottom()
396 highlight_line
= self
.highlight_line
397 highlight
= self
._highlight
398 highlight_text
= self
._highlight
_text
399 disabled
= self
._disabled
403 num_lines
= len(lines
)
405 while block
.isValid():
406 block_number
= block
.blockNumber()
407 if block_number
>= num_lines
:
409 block_geom
= editor
.blockBoundingGeometry(block
)
410 rect
= block_geom
.translated(content_offset
).toRect()
411 if not block
.isVisible() or rect
.top() >= event_rect_bottom
:
414 if block_number
== highlight_line
:
415 painter
.fillRect(rect
.x(), rect
.y(), width
, rect
.height(), highlight
)
416 painter
.setPen(highlight_text
)
418 painter
.setPen(disabled
)
420 line
= lines
[block_number
]
423 text
= fmt
.value(a
, b
)
425 old
, base
, new
= line
426 text
= fmt
.merge_value(old
, base
, new
)
437 block
= block
.next() # pylint: disable=next-method-called
440 class Viewer(QtWidgets
.QFrame
):
441 """Text and image diff viewers"""
443 def __init__(self
, context
, parent
=None):
444 super(Viewer
, self
).__init
__(parent
)
446 self
.context
= context
447 self
.model
= model
= context
.model
450 self
.options
= options
= Options(self
)
451 self
.text
= DiffEditor(context
, options
, self
)
452 self
.image
= imageview
.ImageView(parent
=self
)
453 self
.image
.setFocusPolicy(Qt
.NoFocus
)
455 stack
= self
.stack
= QtWidgets
.QStackedWidget(self
)
456 stack
.addWidget(self
.text
)
457 stack
.addWidget(self
.image
)
459 self
.main_layout
= qtutils
.vbox(defs
.no_margin
, defs
.no_spacing
, self
.stack
)
460 self
.setLayout(self
.main_layout
)
463 model
.images_changed
.connect(self
.set_images
, type=Qt
.QueuedConnection
)
465 # Observe the diff type
466 model
.diff_type_changed
.connect(self
.set_diff_type
, type=Qt
.QueuedConnection
)
468 # Observe the file type
469 model
.file_type_changed
.connect(self
.set_file_type
, type=Qt
.QueuedConnection
)
471 # Observe the image mode combo box
472 options
.image_mode
.currentIndexChanged
.connect(lambda _
: self
.render())
473 options
.zoom_mode
.currentIndexChanged
.connect(lambda _
: self
.render())
475 self
.setFocusProxy(self
.text
)
477 def export_state(self
, state
):
478 state
['show_diff_line_numbers'] = self
.options
.show_line_numbers
.isChecked()
479 state
['image_diff_mode'] = self
.options
.image_mode
.currentIndex()
480 state
['image_zoom_mode'] = self
.options
.zoom_mode
.currentIndex()
481 state
['word_wrap'] = self
.options
.enable_word_wrapping
.isChecked()
484 def apply_state(self
, state
):
485 diff_numbers
= bool(state
.get('show_diff_line_numbers', False))
486 self
.set_line_numbers(diff_numbers
, update
=True)
488 image_mode
= utils
.asint(state
.get('image_diff_mode', 0))
489 self
.options
.image_mode
.set_index(image_mode
)
491 zoom_mode
= utils
.asint(state
.get('image_zoom_mode', 0))
492 self
.options
.zoom_mode
.set_index(zoom_mode
)
494 word_wrap
= bool(state
.get('word_wrap', True))
495 self
.set_word_wrapping(word_wrap
, update
=True)
498 def set_diff_type(self
, diff_type
):
499 """Manage the image and text diff views when selection changes"""
500 # The "diff type" is whether the diff viewer is displaying an image.
501 self
.options
.set_diff_type(diff_type
)
502 if diff_type
== main
.Types
.IMAGE
:
503 self
.stack
.setCurrentWidget(self
.image
)
506 self
.stack
.setCurrentWidget(self
.text
)
508 def set_file_type(self
, file_type
):
509 """Manage the diff options when the file type changes"""
510 # The "file type" is whether the file itself is an image.
511 self
.options
.set_file_type(file_type
)
513 def update_options(self
):
514 """Emit a signal indicating that options have changed"""
515 self
.text
.update_options()
517 def set_line_numbers(self
, enabled
, update
=False):
518 """Enable/disable line numbers in the text widget"""
519 self
.text
.set_line_numbers(enabled
, update
=update
)
521 def set_word_wrapping(self
, enabled
, update
=False):
522 """Enable/disable word wrapping in the text widget"""
523 self
.text
.set_word_wrapping(enabled
, update
=update
)
526 self
.image
.pixmap
= QtGui
.QPixmap()
530 for (image
, unlink
) in self
.images
:
531 if unlink
and core
.exists(image
):
535 def set_images(self
, images
):
542 # In order to comp, we first have to load all the images
543 all_pixmaps
= [QtGui
.QPixmap(image
[0]) for image
in images
]
544 pixmaps
= [pixmap
for pixmap
in all_pixmaps
if not pixmap
.isNull()]
549 self
.pixmaps
= pixmaps
557 mode
= self
.options
.image_mode
.currentIndex()
558 if mode
== self
.options
.SIDE_BY_SIDE
:
559 image
= self
.render_side_by_side()
560 elif mode
== self
.options
.DIFF
:
561 image
= self
.render_diff()
562 elif mode
== self
.options
.XOR
:
563 image
= self
.render_xor()
564 elif mode
== self
.options
.PIXEL_XOR
:
565 image
= self
.render_pixel_xor()
567 image
= self
.render_side_by_side()
569 image
= QtGui
.QPixmap()
570 self
.image
.pixmap
= image
573 zoom_mode
= self
.options
.zoom_mode
.currentIndex()
574 zoom_factor
= self
.options
.zoom_factors
[zoom_mode
][1]
575 if zoom_factor
> 0.0:
576 self
.image
.resetTransform()
577 self
.image
.scale(zoom_factor
, zoom_factor
)
578 poly
= self
.image
.mapToScene(self
.image
.viewport().rect())
579 self
.image
.last_scene_roi
= poly
.boundingRect()
581 def render_side_by_side(self
):
582 # Side-by-side lineup comp
583 pixmaps
= self
.pixmaps
584 width
= sum(pixmap
.width() for pixmap
in pixmaps
)
585 height
= max(pixmap
.height() for pixmap
in pixmaps
)
586 image
= create_image(width
, height
)
589 painter
= create_painter(image
)
591 for pixmap
in pixmaps
:
592 painter
.drawPixmap(x
, 0, pixmap
)
598 def render_comp(self
, comp_mode
):
599 # Get the max size to use as the render canvas
600 pixmaps
= self
.pixmaps
601 if len(pixmaps
) == 1:
604 width
= max(pixmap
.width() for pixmap
in pixmaps
)
605 height
= max(pixmap
.height() for pixmap
in pixmaps
)
606 image
= create_image(width
, height
)
608 painter
= create_painter(image
)
609 for pixmap
in (pixmaps
[0], pixmaps
[-1]):
610 x
= (width
- pixmap
.width()) // 2
611 y
= (height
- pixmap
.height()) // 2
612 painter
.drawPixmap(x
, y
, pixmap
)
613 painter
.setCompositionMode(comp_mode
)
618 def render_diff(self
):
619 comp_mode
= QtGui
.QPainter
.CompositionMode_Difference
620 return self
.render_comp(comp_mode
)
622 def render_xor(self
):
623 comp_mode
= QtGui
.QPainter
.CompositionMode_Xor
624 return self
.render_comp(comp_mode
)
626 def render_pixel_xor(self
):
627 comp_mode
= QtGui
.QPainter
.RasterOp_SourceXorDestination
628 return self
.render_comp(comp_mode
)
631 def create_image(width
, height
):
632 size
= QtCore
.QSize(width
, height
)
633 image
= QtGui
.QImage(size
, QtGui
.QImage
.Format_ARGB32_Premultiplied
)
634 image
.fill(Qt
.transparent
)
638 def create_painter(image
):
639 painter
= QtGui
.QPainter(image
)
640 painter
.fillRect(image
.rect(), Qt
.transparent
)
644 class Options(QtWidgets
.QWidget
):
645 """Provide the options widget used by the editor
647 Actions are registered on the parent widget.
651 # mode combobox indexes
657 def __init__(self
, parent
):
658 super(Options
, self
).__init
__(parent
)
661 self
.ignore_space_at_eol
= self
.add_option(
662 N_('Ignore changes in whitespace at EOL')
665 self
.ignore_space_change
= self
.add_option(
666 N_('Ignore changes in amount of whitespace')
669 self
.ignore_all_space
= self
.add_option(N_('Ignore all whitespace'))
671 self
.function_context
= self
.add_option(
672 N_('Show whole surrounding functions of changes')
675 self
.show_line_numbers
= qtutils
.add_action_bool(
676 self
, N_('Show line numbers'), self
.set_line_numbers
, True
678 self
.enable_word_wrapping
= qtutils
.add_action_bool(
679 self
, N_('Enable word wrapping'), self
.set_word_wrapping
, True
682 self
.options
= qtutils
.create_action_button(
683 tooltip
=N_('Diff Options'), icon
=icons
.configure()
686 self
.toggle_image_diff
= qtutils
.create_action_button(
687 tooltip
=N_('Toggle image diff'), icon
=icons
.visualize()
689 self
.toggle_image_diff
.hide()
691 self
.image_mode
= qtutils
.combo(
692 [N_('Side by side'), N_('Diff'), N_('XOR'), N_('Pixel XOR')]
695 self
.zoom_factors
= (
696 (N_('Zoom to Fit'), 0.0),
704 zoom_modes
= [factor
[0] for factor
in self
.zoom_factors
]
705 self
.zoom_mode
= qtutils
.combo(zoom_modes
, parent
=self
)
707 self
.menu
= menu
= qtutils
.create_menu(N_('Diff Options'), self
.options
)
708 self
.options
.setMenu(menu
)
709 menu
.addAction(self
.ignore_space_at_eol
)
710 menu
.addAction(self
.ignore_space_change
)
711 menu
.addAction(self
.ignore_all_space
)
713 menu
.addAction(self
.function_context
)
714 menu
.addAction(self
.show_line_numbers
)
716 menu
.addAction(self
.enable_word_wrapping
)
719 layout
= qtutils
.hbox(
725 self
.toggle_image_diff
,
727 self
.setLayout(layout
)
730 self
.image_mode
.setFocusPolicy(Qt
.NoFocus
)
731 self
.zoom_mode
.setFocusPolicy(Qt
.NoFocus
)
732 self
.options
.setFocusPolicy(Qt
.NoFocus
)
733 self
.toggle_image_diff
.setFocusPolicy(Qt
.NoFocus
)
734 self
.setFocusPolicy(Qt
.NoFocus
)
736 def set_file_type(self
, file_type
):
737 """Set whether we are viewing an image file type"""
738 is_image
= file_type
== main
.Types
.IMAGE
739 self
.toggle_image_diff
.setVisible(is_image
)
741 def set_diff_type(self
, diff_type
):
742 """Toggle between image and text diffs"""
743 is_text
= diff_type
== main
.Types
.TEXT
744 is_image
= diff_type
== main
.Types
.IMAGE
745 self
.options
.setVisible(is_text
)
746 self
.image_mode
.setVisible(is_image
)
747 self
.zoom_mode
.setVisible(is_image
)
749 self
.toggle_image_diff
.setIcon(icons
.diff())
751 self
.toggle_image_diff
.setIcon(icons
.visualize())
753 def add_option(self
, title
):
754 """Add a diff option which calls update_options() on change"""
755 action
= qtutils
.add_action(self
, title
, self
.update_options
)
756 action
.setCheckable(True)
759 def update_options(self
):
760 """Update diff options in response to UI events"""
761 space_at_eol
= get(self
.ignore_space_at_eol
)
762 space_change
= get(self
.ignore_space_change
)
763 all_space
= get(self
.ignore_all_space
)
764 function_context
= get(self
.function_context
)
765 gitcmds
.update_diff_overrides(
766 space_at_eol
, space_change
, all_space
, function_context
768 self
.widget
.update_options()
770 def set_line_numbers(self
, value
):
771 """Enable / disable line numbers"""
772 self
.widget
.set_line_numbers(value
, update
=False)
774 def set_word_wrapping(self
, value
):
775 """Respond to Qt action callbacks"""
776 self
.widget
.set_word_wrapping(value
, update
=False)
778 def hide_advanced_options(self
):
779 """Hide advanced options that are not applicable to the DiffWidget"""
780 self
.show_line_numbers
.setVisible(False)
781 self
.ignore_space_at_eol
.setVisible(False)
782 self
.ignore_space_change
.setVisible(False)
783 self
.ignore_all_space
.setVisible(False)
784 self
.function_context
.setVisible(False)
787 # pylint: disable=too-many-ancestors
788 class DiffEditor(DiffTextEdit
):
792 options_changed
= Signal()
794 def __init__(self
, context
, options
, parent
):
795 DiffTextEdit
.__init
__(self
, context
, parent
, numbers
=True)
796 self
.context
= context
797 self
.model
= model
= context
.model
798 self
.selection_model
= selection_model
= context
.selection
800 # "Diff Options" tool menu
801 self
.options
= options
803 self
.action_apply_selection
= qtutils
.add_action(
806 self
.apply_selection
,
808 hotkeys
.STAGE_DIFF_ALT
,
811 self
.action_revert_selection
= qtutils
.add_action(
812 self
, 'Revert', self
.revert_selection
, hotkeys
.REVERT
814 self
.action_revert_selection
.setIcon(icons
.undo())
816 self
.action_edit_and_apply_selection
= qtutils
.add_action(
819 partial(self
.apply_selection
, edit
=True),
820 hotkeys
.EDIT_AND_STAGE_DIFF
,
823 self
.action_edit_and_revert_selection
= qtutils
.add_action(
826 partial(self
.revert_selection
, edit
=True),
827 hotkeys
.EDIT_AND_REVERT
,
829 self
.action_edit_and_revert_selection
.setIcon(icons
.undo())
830 self
.launch_editor
= actions
.launch_editor_at_line(
831 context
, self
, hotkeys
.EDIT_SHORT
, *hotkeys
.ACCEPT
833 self
.launch_difftool
= actions
.launch_difftool(context
, self
)
834 self
.stage_or_unstage
= actions
.stage_or_unstage(context
, self
)
836 # Emit up/down signals so that they can be routed by the main widget
837 self
.move_up
= actions
.move_up(self
)
838 self
.move_down
= actions
.move_down(self
)
840 model
.diff_text_updated
.connect(self
.set_diff
, type=Qt
.QueuedConnection
)
842 selection_model
.selection_changed
.connect(
843 self
.refresh
, type=Qt
.QueuedConnection
845 # Update the selection model when the cursor changes
846 self
.cursorPositionChanged
.connect(self
._update
_line
_number
)
848 qtutils
.connect_button(options
.toggle_image_diff
, self
.toggle_diff_type
)
850 def toggle_diff_type(self
):
851 cmds
.do(cmds
.ToggleDiffType
, self
.context
)
855 s
= self
.selection_model
.selection()
857 if model
.is_partially_stageable():
858 item
= s
.modified
[0] if s
.modified
else None
859 if item
in model
.submodules
:
861 elif item
not in model
.unstaged_deleted
:
863 self
.action_revert_selection
.setEnabled(enabled
)
865 def set_line_numbers(self
, enabled
, update
=False):
866 """Enable/disable the diff line number display"""
867 self
.numbers
.setVisible(enabled
)
869 with qtutils
.BlockSignals(self
.options
.show_line_numbers
):
870 self
.options
.show_line_numbers
.setChecked(enabled
)
871 # Refresh the display. Not doing this results in the display not
872 # correctly displaying the line numbers widget until the text scrolls.
873 self
.set_value(self
.value())
875 def update_options(self
):
876 self
.options_changed
.emit()
878 def create_context_menu(self
, event_pos
):
879 """Override create_context_menu() to display a completely custom menu"""
880 menu
= super(DiffEditor
, self
).create_context_menu(event_pos
)
881 context
= self
.context
883 s
= self
.selection_model
.selection()
884 filename
= self
.selection_model
.filename()
886 # These menu actions will be inserted at the start of the widget.
887 current_actions
= menu
.actions()
889 add_action
= menu_actions
.append
891 if model
.is_stageable() or model
.is_unstageable():
892 if model
.is_stageable():
893 self
.stage_or_unstage
.setText(N_('Stage'))
894 self
.stage_or_unstage
.setIcon(icons
.add())
896 self
.stage_or_unstage
.setText(N_('Unstage'))
897 self
.stage_or_unstage
.setIcon(icons
.remove())
898 add_action(self
.stage_or_unstage
)
900 if model
.is_partially_stageable():
901 item
= s
.modified
[0] if s
.modified
else None
902 if item
in model
.submodules
:
903 path
= core
.abspath(item
)
904 action
= qtutils
.add_action_with_icon(
908 cmds
.run(cmds
.Stage
, context
, s
.modified
),
909 hotkeys
.STAGE_SELECTION
,
913 action
= qtutils
.add_action_with_icon(
916 N_('Launch git-cola'),
917 cmds
.run(cmds
.OpenRepo
, context
, path
),
920 elif item
not in model
.unstaged_deleted
:
921 if self
.has_selection():
922 apply_text
= N_('Stage Selected Lines')
923 edit_and_apply_text
= N_('Edit Selected Lines to Stage...')
924 revert_text
= N_('Revert Selected Lines...')
925 edit_and_revert_text
= N_('Edit Selected Lines to Revert...')
927 apply_text
= N_('Stage Diff Hunk')
928 edit_and_apply_text
= N_('Edit Diff Hunk to Stage...')
929 revert_text
= N_('Revert Diff Hunk...')
930 edit_and_revert_text
= N_('Edit Diff Hunk to Revert...')
932 self
.action_apply_selection
.setText(apply_text
)
933 self
.action_apply_selection
.setIcon(icons
.add())
934 add_action(self
.action_apply_selection
)
936 self
.action_edit_and_apply_selection
.setText(edit_and_apply_text
)
937 self
.action_edit_and_apply_selection
.setIcon(icons
.add())
938 add_action(self
.action_edit_and_apply_selection
)
940 self
.action_revert_selection
.setText(revert_text
)
941 add_action(self
.action_revert_selection
)
943 self
.action_edit_and_revert_selection
.setText(edit_and_revert_text
)
944 add_action(self
.action_edit_and_revert_selection
)
946 if s
.staged
and model
.is_unstageable():
948 if item
in model
.submodules
:
949 path
= core
.abspath(item
)
950 action
= qtutils
.add_action_with_icon(
954 cmds
.run(cmds
.Unstage
, context
, s
.staged
),
955 hotkeys
.STAGE_SELECTION
,
959 qtutils
.add_action_with_icon(
962 N_('Launch git-cola'),
963 cmds
.run(cmds
.OpenRepo
, context
, path
),
967 elif item
not in model
.staged_deleted
:
968 if self
.has_selection():
969 apply_text
= N_('Unstage Selected Lines')
970 edit_and_apply_text
= N_('Edit Selected Lines to Unstage...')
972 apply_text
= N_('Unstage Diff Hunk')
973 edit_and_apply_text
= N_('Edit Diff Hunk to Unstage...')
975 self
.action_apply_selection
.setText(apply_text
)
976 self
.action_apply_selection
.setIcon(icons
.remove())
977 add_action(self
.action_apply_selection
)
979 self
.action_edit_and_apply_selection
.setText(edit_and_apply_text
)
980 self
.action_edit_and_apply_selection
.setIcon(icons
.remove())
981 add_action(self
.action_edit_and_apply_selection
)
983 if model
.is_stageable() or model
.is_unstageable():
984 # Do not show the "edit" action when the file does not exist.
985 # Untracked files exist by definition.
986 if filename
and core
.exists(filename
):
987 add_action(qtutils
.menu_separator(menu
))
988 add_action(self
.launch_editor
)
990 # Removed files can still be diffed.
991 add_action(self
.launch_difftool
)
993 # Add the Previous/Next File actions, which improves discoverability
994 # of their associated shortcuts
995 add_action(qtutils
.menu_separator(menu
))
996 add_action(self
.move_up
)
997 add_action(self
.move_down
)
998 add_action(qtutils
.menu_separator(menu
))
1001 first_action
= current_actions
[0]
1004 menu
.insertActions(first_action
, menu_actions
)
1008 def mousePressEvent(self
, event
):
1009 if event
.button() == Qt
.RightButton
:
1010 # Intercept right-click to move the cursor to the current position.
1011 # setTextCursor() clears the selection so this is only done when
1012 # nothing is selected.
1013 if not self
.has_selection():
1014 cursor
= self
.cursorForPosition(event
.pos())
1015 self
.setTextCursor(cursor
)
1017 return super(DiffEditor
, self
).mousePressEvent(event
)
1019 def setPlainText(self
, text
):
1020 """setPlainText(str) while retaining scrollbar positions"""
1023 highlight
= mode
not in (
1026 model
.mode_untracked
,
1028 self
.highlighter
.set_enabled(highlight
)
1030 scrollbar
= self
.verticalScrollBar()
1032 scrollvalue
= get(scrollbar
)
1039 DiffTextEdit
.setPlainText(self
, text
)
1041 if scrollbar
and scrollvalue
is not None:
1042 scrollbar
.setValue(scrollvalue
)
1044 def apply_selection(self
, *, edit
=False):
1046 s
= self
.selection_model
.single_selection()
1047 if model
.is_partially_stageable() and (s
.modified
or s
.untracked
):
1048 self
.process_diff_selection(edit
=edit
)
1049 elif model
.is_unstageable():
1050 self
.process_diff_selection(reverse
=True, edit
=edit
)
1052 def revert_selection(self
, *, edit
=False):
1053 """Destructively revert selected lines or hunk from a worktree file."""
1056 if self
.has_selection():
1057 title
= N_('Revert Selected Lines?')
1058 ok_text
= N_('Revert Selected Lines')
1060 title
= N_('Revert Diff Hunk?')
1061 ok_text
= N_('Revert Diff Hunk')
1063 if not Interaction
.confirm(
1066 'This operation drops uncommitted changes.\n'
1067 'These changes cannot be recovered.'
1069 N_('Revert the uncommitted changes?'),
1075 self
.process_diff_selection(reverse
=True, apply_to_worktree
=True, edit
=edit
)
1077 def extract_patch(self
, reverse
=False):
1078 first_line_idx
, last_line_idx
= self
.selected_lines()
1079 patch
= diffparse
.Patch
.parse(self
.model
.filename
, self
.model
.diff_text
)
1080 if self
.has_selection():
1081 return patch
.extract_subset(first_line_idx
, last_line_idx
, reverse
=reverse
)
1083 return patch
.extract_hunk(first_line_idx
, reverse
=reverse
)
1085 def patch_encoding(self
):
1086 if isinstance(self
.model
.diff_text
, core
.UStr
):
1087 # original encoding must prevail
1088 return self
.model
.diff_text
.encoding
1090 return self
.context
.cfg
.file_encoding(self
.model
.filename
)
1092 def process_diff_selection(
1093 self
, reverse
=False, apply_to_worktree
=False, edit
=False
1095 """Implement un/staging of the selected line(s) or hunk."""
1096 if self
.selection_model
.is_empty():
1098 patch
= self
.extract_patch(reverse
)
1099 if not patch
.has_changes():
1101 patch_encoding
= self
.patch_encoding()
1109 apply_to_worktree
=apply_to_worktree
,
1111 if not patch
.has_changes():
1122 def _update_line_number(self
):
1123 """Update the selection model when the cursor changes"""
1124 self
.selection_model
.line_number
= self
.numbers
.current_line()
1127 class DiffWidget(QtWidgets
.QWidget
):
1128 """Display commit metadata and text diffs"""
1130 def __init__(self
, context
, parent
, is_commit
=False, options
=None):
1131 QtWidgets
.QWidget
.__init
__(self
, parent
)
1133 self
.context
= context
1135 self
.oid_start
= None
1137 self
.options
= options
1139 author_font
= QtGui
.QFont(self
.font())
1140 author_font
.setPointSize(int(author_font
.pointSize() * 1.1))
1142 summary_font
= QtGui
.QFont(author_font
)
1143 summary_font
.setWeight(QtGui
.QFont
.Bold
)
1145 policy
= QtWidgets
.QSizePolicy(
1146 QtWidgets
.QSizePolicy
.MinimumExpanding
, QtWidgets
.QSizePolicy
.Minimum
1149 self
.gravatar_label
= gravatar
.GravatarLabel(self
.context
, parent
=self
)
1151 self
.author_label
= TextLabel()
1152 self
.author_label
.setTextFormat(Qt
.RichText
)
1153 self
.author_label
.setFont(author_font
)
1154 self
.author_label
.setSizePolicy(policy
)
1155 self
.author_label
.setAlignment(Qt
.AlignBottom
)
1156 self
.author_label
.elide()
1158 self
.date_label
= TextLabel()
1159 self
.date_label
.setTextFormat(Qt
.PlainText
)
1160 self
.date_label
.setSizePolicy(policy
)
1161 self
.date_label
.setAlignment(Qt
.AlignTop
)
1162 self
.date_label
.hide()
1164 self
.summary_label
= TextLabel()
1165 self
.summary_label
.setTextFormat(Qt
.PlainText
)
1166 self
.summary_label
.setFont(summary_font
)
1167 self
.summary_label
.setSizePolicy(policy
)
1168 self
.summary_label
.setAlignment(Qt
.AlignTop
)
1169 self
.summary_label
.elide()
1171 self
.oid_label
= TextLabel()
1172 self
.oid_label
.setTextFormat(Qt
.PlainText
)
1173 self
.oid_label
.setSizePolicy(policy
)
1174 self
.oid_label
.setAlignment(Qt
.AlignTop
)
1175 self
.oid_label
.elide()
1177 self
.diff
= DiffTextEdit(context
, self
, is_commit
=is_commit
, whitespace
=False)
1179 self
.info_layout
= qtutils
.vbox(
1188 self
.logo_layout
= qtutils
.hbox(
1189 defs
.no_margin
, defs
.button_spacing
, self
.gravatar_label
, self
.info_layout
1191 self
.logo_layout
.setContentsMargins(defs
.margin
, 0, defs
.margin
, 0)
1193 self
.main_layout
= qtutils
.vbox(
1194 defs
.no_margin
, defs
.spacing
, self
.logo_layout
, self
.diff
1196 self
.setLayout(self
.main_layout
)
1198 self
.set_tabwidth(prefs
.tabwidth(context
))
1200 def set_tabwidth(self
, width
):
1201 self
.diff
.set_tabwidth(width
)
1203 def set_word_wrapping(self
, enabled
, update
=False):
1204 """Enable and disable word wrapping"""
1205 self
.diff
.set_word_wrapping(enabled
, update
=update
)
1207 def set_options(self
, options
):
1208 """Register an options widget"""
1209 self
.options
= options
1210 self
.diff
.set_options(options
)
1212 def start_diff_task(self
, task
):
1213 """Clear the display and start a diff-gathering task"""
1214 self
.diff
.save_scrollbar()
1215 self
.diff
.set_loading_message()
1216 self
.context
.runtask
.start(task
, result
=self
.set_diff
)
1218 def set_diff_oid(self
, oid
, filename
=None):
1219 """Set the diff from a single commit object ID"""
1220 task
= DiffInfoTask(self
.context
, oid
, filename
)
1221 self
.start_diff_task(task
)
1223 def set_diff_range(self
, start
, end
, filename
=None):
1224 task
= DiffRangeTask(self
.context
, start
+ '~', end
, filename
)
1225 self
.start_diff_task(task
)
1227 def commits_selected(self
, commits
):
1228 """Display an appropriate diff when commits are selected"""
1234 email
= commit
.email
or ''
1235 summary
= commit
.summary
or ''
1236 author
= commit
.author
or ''
1237 self
.set_details(oid
, author
, email
, '', summary
)
1240 if len(commits
) > 1:
1241 start
, end
= commits
[-1], commits
[0]
1242 self
.set_diff_range(start
.oid
, end
.oid
)
1243 self
.oid_start
= start
1246 self
.set_diff_oid(oid
)
1247 self
.oid_start
= None
1250 def set_diff(self
, diff
):
1251 """Set the diff text"""
1252 self
.diff
.set_diff(diff
)
1254 def set_details(self
, oid
, author
, email
, date
, summary
):
1255 template_args
= {'author': author
, 'email': email
, 'summary': summary
}
1257 """%(author)s <"""
1258 """<a href="mailto:%(email)s">"""
1259 """%(email)s</a>>""" % template_args
1261 author_template
= '%(author)s <%(email)s>' % template_args
1263 self
.date_label
.set_text(date
)
1264 self
.date_label
.setVisible(bool(date
))
1265 self
.oid_label
.set_text(oid
)
1266 self
.author_label
.set_template(author_text
, author_template
)
1267 self
.summary_label
.set_text(summary
)
1268 self
.gravatar_label
.set_email(email
)
1271 self
.date_label
.set_text('')
1272 self
.oid_label
.set_text('')
1273 self
.author_label
.set_text('')
1274 self
.summary_label
.set_text('')
1275 self
.gravatar_label
.clear()
1278 def files_selected(self
, filenames
):
1279 """Update the view when a filename is selected"""
1282 oid_start
= self
.oid_start
1283 oid_end
= self
.oid_end
1284 if oid_start
and oid_end
:
1285 self
.set_diff_range(oid_start
.oid
, oid_end
.oid
, filename
=filenames
[0])
1287 self
.set_diff_oid(self
.oid
, filename
=filenames
[0])
1290 class TextLabel(QtWidgets
.QLabel
):
1291 def __init__(self
, parent
=None):
1292 QtWidgets
.QLabel
.__init
__(self
, parent
)
1293 self
.setTextInteractionFlags(
1294 Qt
.TextSelectableByMouse | Qt
.LinksAccessibleByMouse
1300 self
._metrics
= QtGui
.QFontMetrics(self
.font())
1301 self
.setOpenExternalLinks(True)
1306 def set_text(self
, text
):
1307 self
.set_template(text
, text
)
1309 def set_template(self
, text
, template
):
1310 self
._display
= text
1312 self
._template
= template
1313 self
.update_text(self
.width())
1314 self
.setText(self
._display
)
1316 def update_text(self
, width
):
1317 self
._display
= self
._text
1320 text
= self
._metrics
.elidedText(self
._template
, Qt
.ElideRight
, width
- 2)
1321 if text
!= self
._template
:
1322 self
._display
= text
1325 def setFont(self
, font
):
1326 self
._metrics
= QtGui
.QFontMetrics(font
)
1327 QtWidgets
.QLabel
.setFont(self
, font
)
1329 def resizeEvent(self
, event
):
1331 self
.update_text(event
.size().width())
1332 with qtutils
.BlockSignals(self
):
1333 self
.setText(self
._display
)
1334 QtWidgets
.QLabel
.resizeEvent(self
, event
)
1337 class DiffInfoTask(qtutils
.Task
):
1338 """Gather diffs for a single commit"""
1339 def __init__(self
, context
, oid
, filename
):
1340 qtutils
.Task
.__init
__(self
)
1341 self
.context
= context
1343 self
.filename
= filename
1346 context
= self
.context
1348 return gitcmds
.diff_info(context
, oid
, filename
=self
.filename
)
1351 class DiffRangeTask(qtutils
.Task
):
1352 """Gather diffs for a range of commits"""
1353 def __init__(self
, context
, start
, end
, filename
):
1354 qtutils
.Task
.__init
__(self
)
1355 self
.context
= context
1358 self
.filename
= filename
1361 context
= self
.context
1362 return gitcmds
.diff_range(context
, self
.start
, self
.end
, filename
=self
.filename
)