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
30 from .text
import TextSearchWidget
32 from . import imageview
35 class DiffSyntaxHighlighter(QtGui
.QSyntaxHighlighter
):
36 """Implements the diff syntax highlighting"""
41 DIFF_FILE_HEADER_STATE
= 2
46 DIFF_FILE_HEADER_START_RGX
= re
.compile(r
'diff --git a/.* b/.*')
47 DIFF_HUNK_HEADER_RGX
= re
.compile(
48 r
'(?:@@ -[0-9,]+ \+[0-9,]+ @@)|(?:@@@ (?:-[0-9,]+ ){2}\+[0-9,]+ @@@)'
50 BAD_WHITESPACE_RGX
= re
.compile(r
'\s+$')
52 def __init__(self
, context
, doc
, whitespace
=True, is_commit
=False):
53 QtGui
.QSyntaxHighlighter
.__init
__(self
, doc
)
54 self
.whitespace
= whitespace
56 self
.is_commit
= is_commit
58 QPalette
= QtGui
.QPalette
61 disabled
= palette
.color(QPalette
.Disabled
, QPalette
.Text
)
62 header
= qtutils
.rgb_hex(disabled
)
64 dark
= palette
.color(QPalette
.Base
).lightnessF() < 0.5
66 self
.color_text
= qtutils
.RGB(cfg
.color('text', '030303'))
67 self
.color_add
= qtutils
.RGB(cfg
.color('add', '77aa77' if dark
else 'd2ffe4'))
68 self
.color_remove
= qtutils
.RGB(
69 cfg
.color('remove', 'aa7777' if dark
else 'fee0e4')
71 self
.color_header
= qtutils
.RGB(cfg
.color('header', header
))
73 self
.diff_header_fmt
= qtutils
.make_format(fg
=self
.color_header
)
74 self
.bold_diff_header_fmt
= qtutils
.make_format(fg
=self
.color_header
, bold
=True)
76 self
.diff_add_fmt
= qtutils
.make_format(fg
=self
.color_text
, bg
=self
.color_add
)
77 self
.diff_remove_fmt
= qtutils
.make_format(
78 fg
=self
.color_text
, bg
=self
.color_remove
80 self
.bad_whitespace_fmt
= qtutils
.make_format(bg
=Qt
.red
)
81 self
.setCurrentBlockState(self
.INITIAL_STATE
)
83 def set_enabled(self
, enabled
):
84 self
.enabled
= enabled
86 def highlightBlock(self
, text
):
87 if not self
.enabled
or not text
:
89 # Aliases for quick local access
90 initial_state
= self
.INITIAL_STATE
91 default_state
= self
.DEFAULT_STATE
92 diff_state
= self
.DIFF_STATE
93 diffstat_state
= self
.DIFFSTAT_STATE
94 diff_file_header_state
= self
.DIFF_FILE_HEADER_STATE
95 submodule_state
= self
.SUBMODULE_STATE
96 end_state
= self
.END_STATE
98 diff_file_header_start_rgx
= self
.DIFF_FILE_HEADER_START_RGX
99 diff_hunk_header_rgx
= self
.DIFF_HUNK_HEADER_RGX
100 bad_whitespace_rgx
= self
.BAD_WHITESPACE_RGX
102 diff_header_fmt
= self
.diff_header_fmt
103 bold_diff_header_fmt
= self
.bold_diff_header_fmt
104 diff_add_fmt
= self
.diff_add_fmt
105 diff_remove_fmt
= self
.diff_remove_fmt
106 bad_whitespace_fmt
= self
.bad_whitespace_fmt
108 state
= self
.previousBlockState()
109 if state
== initial_state
:
110 if text
.startswith('Submodule '):
111 state
= submodule_state
112 elif text
.startswith('diff --git '):
113 state
= diffstat_state
115 state
= default_state
117 state
= diffstat_state
119 if state
== diffstat_state
:
120 if diff_file_header_start_rgx
.match(text
):
121 state
= diff_file_header_state
122 self
.setFormat(0, len(text
), diff_header_fmt
)
123 elif diff_hunk_header_rgx
.match(text
):
125 self
.setFormat(0, len(text
), bold_diff_header_fmt
)
128 self
.setFormat(0, i
, bold_diff_header_fmt
)
129 self
.setFormat(i
, len(text
) - i
, diff_header_fmt
)
131 self
.setFormat(0, len(text
), diff_header_fmt
)
132 elif state
== diff_file_header_state
:
133 if diff_hunk_header_rgx
.match(text
):
135 self
.setFormat(0, len(text
), bold_diff_header_fmt
)
137 self
.setFormat(0, len(text
), diff_header_fmt
)
138 elif state
== diff_state
:
139 if diff_file_header_start_rgx
.match(text
):
140 state
= diff_file_header_state
141 self
.setFormat(0, len(text
), diff_header_fmt
)
142 elif diff_hunk_header_rgx
.match(text
):
143 self
.setFormat(0, len(text
), bold_diff_header_fmt
)
144 elif text
.startswith('-'):
148 self
.setFormat(0, len(text
), diff_remove_fmt
)
149 elif text
.startswith('+'):
150 self
.setFormat(0, len(text
), diff_add_fmt
)
152 m
= bad_whitespace_rgx
.search(text
)
155 self
.setFormat(i
, len(text
) - i
, bad_whitespace_fmt
)
157 self
.setCurrentBlockState(state
)
160 # pylint: disable=too-many-ancestors
161 class DiffTextEdit(VimHintedPlainTextEdit
):
162 """A textedit for interacting with diff text"""
165 self
, context
, parent
, is_commit
=False, whitespace
=True, numbers
=False
167 VimHintedPlainTextEdit
.__init
__(self
, context
, '', parent
=parent
)
168 # Diff/patch syntax highlighter
169 self
.highlighter
= DiffSyntaxHighlighter(
170 context
, self
.document(), is_commit
=is_commit
, whitespace
=whitespace
173 self
.numbers
= DiffLineNumbers(context
, self
)
177 self
.scrollvalue
= None
179 self
.copy_diff_action
= qtutils
.add_action(
185 self
.copy_diff_action
.setIcon(icons
.copy())
186 self
.copy_diff_action
.setEnabled(False)
187 self
.menu_actions
.append(self
.copy_diff_action
)
189 # pylint: disable=no-member
190 self
.cursorPositionChanged
.connect(self
._cursor
_changed
, Qt
.QueuedConnection
)
191 self
.selectionChanged
.connect(self
._selection
_changed
, Qt
.QueuedConnection
)
193 def setFont(self
, font
):
194 """Override setFont() so that we can use a custom "block" cursor"""
195 super(DiffTextEdit
, self
).setFont(font
)
196 if prefs
.block_cursor(self
.context
):
197 metrics
= QtGui
.QFontMetrics(font
)
198 width
= metrics
.width('M')
199 self
.setCursorWidth(width
)
201 def _cursor_changed(self
):
202 """Update the line number display when the cursor changes"""
203 line_number
= max(0, self
.textCursor().blockNumber())
205 self
.numbers
.set_highlighted(line_number
)
207 def _selection_changed(self
):
208 """Respond to selection changes"""
209 selected
= bool(self
.selected_text())
210 self
.copy_diff_action
.setEnabled(selected
)
212 def resizeEvent(self
, event
):
213 super(DiffTextEdit
, self
).resizeEvent(event
)
215 self
.numbers
.refresh_size()
217 def save_scrollbar(self
):
218 """Save the scrollbar value, but only on the first call"""
219 if self
.scrollvalue
is None:
220 scrollbar
= self
.verticalScrollBar()
222 scrollvalue
= get(scrollbar
)
225 self
.scrollvalue
= scrollvalue
227 def restore_scrollbar(self
):
228 """Restore the scrollbar and clear state"""
229 scrollbar
= self
.verticalScrollBar()
230 scrollvalue
= self
.scrollvalue
231 if scrollbar
and scrollvalue
is not None:
232 scrollbar
.setValue(scrollvalue
)
233 self
.scrollvalue
= None
235 def set_loading_message(self
):
236 """Add a pending loading message in the diff view"""
237 self
.hint
.set_value('+++ ' + N_('Loading...'))
240 def set_diff(self
, diff
):
241 """Set the diff text, but save the scrollbar"""
242 diff
= diff
.rstrip('\n') # diffs include two empty newlines
243 self
.save_scrollbar()
245 self
.hint
.set_value('')
247 self
.numbers
.set_diff(diff
)
250 self
.restore_scrollbar()
252 def selected_diff_stripped(self
):
253 """Return the selected diff stripped of any diff characters"""
254 sep
, selection
= self
.selected_text_lines()
255 return sep
.join(_strip_diff(line
) for line
in selection
)
258 """Copy the selected diff text stripped of any diff prefix characters"""
259 text
= self
.selected_diff_stripped()
260 qtutils
.set_clipboard(text
)
262 def selected_lines(self
):
263 """Return selected lines"""
264 cursor
= self
.textCursor()
265 selection_start
= cursor
.selectionStart()
266 selection_end
= max(selection_start
, cursor
.selectionEnd() - 1)
273 for line_idx
, line
in enumerate(get(self
, default
='').splitlines()):
274 line_end
= line_start
+ len(line
)
275 if line_start
<= selection_start
<= line_end
:
276 first_line_idx
= line_idx
277 if line_start
<= selection_end
<= line_end
:
278 last_line_idx
= line_idx
280 line_start
= line_end
+ 1
282 if first_line_idx
== -1:
283 first_line_idx
= line_idx
285 if last_line_idx
== -1:
286 last_line_idx
= line_idx
288 return first_line_idx
, last_line_idx
290 def selected_text_lines(self
):
291 """Return selected lines and the CRLF / LF separator"""
292 first_line_idx
, last_line_idx
= self
.selected_lines()
293 text
= get(self
, default
='')
296 for line_idx
, line
in enumerate(text
.split(sep
)):
297 if first_line_idx
<= line_idx
<= last_line_idx
:
303 """Return either CRLF or LF based on the content"""
311 def _strip_diff(value
):
312 """Remove +/-/<space> from a selection"""
313 if value
.startswith(('+', '-', ' ')):
318 class DiffLineNumbers(TextDecorator
):
319 def __init__(self
, context
, parent
):
320 TextDecorator
.__init
__(self
, parent
)
321 self
.highlight_line
= -1
323 self
.parser
= diffparse
.DiffLines()
324 self
.formatter
= diffparse
.FormatDigits()
326 self
.setFont(qtutils
.diff_font(context
))
327 self
._char
_width
= self
.fontMetrics().width('0')
329 QPalette
= QtGui
.QPalette
330 self
._palette
= palette
= self
.palette()
331 self
._base
= palette
.color(QtGui
.QPalette
.Base
)
332 self
._highlight
= palette
.color(QPalette
.Highlight
)
333 self
._highlight
.setAlphaF(0.3)
334 self
._highlight
_text
= palette
.color(QPalette
.HighlightedText
)
335 self
._window
= palette
.color(QPalette
.Window
)
336 self
._disabled
= palette
.color(QPalette
.Disabled
, QPalette
.Text
)
338 def set_diff(self
, diff
):
339 self
.lines
= self
.parser
.parse(diff
)
340 self
.formatter
.set_digits(self
.parser
.digits())
342 def width_hint(self
):
343 if not self
.isVisible():
349 extra
= 3 # one space in-between, one space after
352 extra
= 2 # one space in-between, one space after
354 digits
= parser
.digits() * columns
356 return defs
.margin
+ (self
._char
_width
* (digits
+ extra
))
358 def set_highlighted(self
, line_number
):
359 """Set the line to highlight"""
360 self
.highlight_line
= line_number
362 def current_line(self
):
364 if lines
and self
.highlight_line
>= 0:
365 # Find the next valid line
366 for i
in range(self
.highlight_line
, len(lines
)):
367 # take the "new" line number: last value in tuple
368 line_number
= lines
[i
][-1]
372 # Find the previous valid line
373 for i
in range(self
.highlight_line
- 1, -1, -1):
374 # take the "new" line number: last value in tuple
376 line_number
= lines
[i
][-1]
381 def paintEvent(self
, event
):
382 """Paint the line number"""
386 painter
= QtGui
.QPainter(self
)
387 painter
.fillRect(event
.rect(), self
._base
)
390 content_offset
= editor
.contentOffset()
391 block
= editor
.firstVisibleBlock()
393 text_width
= width
- (defs
.margin
* 2)
394 text_flags
= Qt
.AlignRight | Qt
.AlignVCenter
395 event_rect_bottom
= event
.rect().bottom()
397 highlight_line
= self
.highlight_line
398 highlight
= self
._highlight
399 highlight_text
= self
._highlight
_text
400 disabled
= self
._disabled
404 num_lines
= len(lines
)
406 while block
.isValid():
407 block_number
= block
.blockNumber()
408 if block_number
>= num_lines
:
410 block_geom
= editor
.blockBoundingGeometry(block
)
411 rect
= block_geom
.translated(content_offset
).toRect()
412 if not block
.isVisible() or rect
.top() >= event_rect_bottom
:
415 if block_number
== highlight_line
:
416 painter
.fillRect(rect
.x(), rect
.y(), width
, rect
.height(), highlight
)
417 painter
.setPen(highlight_text
)
419 painter
.setPen(disabled
)
421 line
= lines
[block_number
]
424 text
= fmt
.value(a
, b
)
426 old
, base
, new
= line
427 text
= fmt
.merge_value(old
, base
, new
)
438 block
= block
.next() # pylint: disable=next-method-called
441 class Viewer(QtWidgets
.QFrame
):
442 """Text and image diff viewers"""
446 def __init__(self
, context
, parent
=None):
447 super(Viewer
, self
).__init
__(parent
)
449 self
.context
= context
450 self
.model
= model
= context
.model
453 self
.options
= options
= Options(self
)
454 self
.text
= DiffEditor(context
, options
, self
)
455 self
.image
= imageview
.ImageView(parent
=self
)
456 self
.image
.setFocusPolicy(Qt
.NoFocus
)
457 self
.search_widget
= TextSearchWidget(self
)
458 self
.search_widget
.hide()
460 stack
= self
.stack
= QtWidgets
.QStackedWidget(self
)
461 stack
.addWidget(self
.text
)
462 stack
.addWidget(self
.image
)
464 self
.main_layout
= qtutils
.vbox(
470 self
.setLayout(self
.main_layout
)
473 model
.images_changed
.connect(self
.set_images
, type=Qt
.QueuedConnection
)
475 # Observe the diff type
476 model
.diff_type_changed
.connect(self
.set_diff_type
, type=Qt
.QueuedConnection
)
478 # Observe the file type
479 model
.file_type_changed
.connect(self
.set_file_type
, type=Qt
.QueuedConnection
)
481 # Observe the image mode combo box
482 options
.image_mode
.currentIndexChanged
.connect(lambda _
: self
.render())
483 options
.zoom_mode
.currentIndexChanged
.connect(lambda _
: self
.render())
485 self
.setFocusProxy(self
.text
)
487 self
.search_widget
.search_text
.connect(self
.search_text
)
489 self
.search_action
= qtutils
.add_action(
491 N_('Search in Diff'),
492 self
.show_search_diff
,
495 self
.search_next_action
= qtutils
.add_action(
497 N_('Find next item'),
498 self
.search_widget
.search
,
501 self
.search_prev_action
= qtutils
.add_action(
503 N_('Find previous item'),
504 self
.search_widget
.search_backwards
,
508 def show_search_diff(self
):
509 """Show a dialog for searching diffs"""
510 # The diff search is only active in text mode.
511 if self
.stack
.currentIndex() != self
.INDEX_TEXT
:
513 if not self
.search_widget
.isVisible():
514 self
.search_widget
.show()
515 self
.search_widget
.setFocus(True)
517 def search_text(self
, text
, backwards
):
518 """Search the diff text for the given text"""
519 cursor
= self
.text
.textCursor()
520 if cursor
.hasSelection():
521 selected_text
= cursor
.selectedText()
522 case_sensitive
= self
.search_widget
.is_case_sensitive()
523 if text_matches(case_sensitive
, selected_text
, text
):
525 position
= cursor
.selectionStart()
527 position
= cursor
.selectionEnd()
530 position
= cursor
.selectionEnd()
532 position
= cursor
.selectionStart()
533 cursor
.setPosition(position
)
534 self
.text
.setTextCursor(cursor
)
536 flags
= self
.search_widget
.find_flags(backwards
)
537 if not self
.text
.find(text
, flags
):
539 location
= QtGui
.QTextCursor
.End
541 location
= QtGui
.QTextCursor
.Start
542 cursor
.movePosition(location
, QtGui
.QTextCursor
.MoveAnchor
)
543 self
.text
.setTextCursor(cursor
)
544 self
.text
.find(text
, flags
)
546 def export_state(self
, state
):
547 state
['show_diff_line_numbers'] = self
.options
.show_line_numbers
.isChecked()
548 state
['image_diff_mode'] = self
.options
.image_mode
.currentIndex()
549 state
['image_zoom_mode'] = self
.options
.zoom_mode
.currentIndex()
550 state
['word_wrap'] = self
.options
.enable_word_wrapping
.isChecked()
553 def apply_state(self
, state
):
554 diff_numbers
= bool(state
.get('show_diff_line_numbers', False))
555 self
.set_line_numbers(diff_numbers
, update
=True)
557 image_mode
= utils
.asint(state
.get('image_diff_mode', 0))
558 self
.options
.image_mode
.set_index(image_mode
)
560 zoom_mode
= utils
.asint(state
.get('image_zoom_mode', 0))
561 self
.options
.zoom_mode
.set_index(zoom_mode
)
563 word_wrap
= bool(state
.get('word_wrap', True))
564 self
.set_word_wrapping(word_wrap
, update
=True)
567 def set_diff_type(self
, diff_type
):
568 """Manage the image and text diff views when selection changes"""
569 # The "diff type" is whether the diff viewer is displaying an image.
570 self
.options
.set_diff_type(diff_type
)
571 if diff_type
== main
.Types
.IMAGE
:
572 self
.stack
.setCurrentWidget(self
.image
)
573 self
.search_widget
.hide()
576 self
.stack
.setCurrentWidget(self
.text
)
578 def set_file_type(self
, file_type
):
579 """Manage the diff options when the file type changes"""
580 # The "file type" is whether the file itself is an image.
581 self
.options
.set_file_type(file_type
)
583 def update_options(self
):
584 """Emit a signal indicating that options have changed"""
585 self
.text
.update_options()
587 def set_line_numbers(self
, enabled
, update
=False):
588 """Enable/disable line numbers in the text widget"""
589 self
.text
.set_line_numbers(enabled
, update
=update
)
591 def set_word_wrapping(self
, enabled
, update
=False):
592 """Enable/disable word wrapping in the text widget"""
593 self
.text
.set_word_wrapping(enabled
, update
=update
)
596 self
.image
.pixmap
= QtGui
.QPixmap()
600 for (image
, unlink
) in self
.images
:
601 if unlink
and core
.exists(image
):
605 def set_images(self
, images
):
612 # In order to comp, we first have to load all the images
613 all_pixmaps
= [QtGui
.QPixmap(image
[0]) for image
in images
]
614 pixmaps
= [pixmap
for pixmap
in all_pixmaps
if not pixmap
.isNull()]
619 self
.pixmaps
= pixmaps
627 mode
= self
.options
.image_mode
.currentIndex()
628 if mode
== self
.options
.SIDE_BY_SIDE
:
629 image
= self
.render_side_by_side()
630 elif mode
== self
.options
.DIFF
:
631 image
= self
.render_diff()
632 elif mode
== self
.options
.XOR
:
633 image
= self
.render_xor()
634 elif mode
== self
.options
.PIXEL_XOR
:
635 image
= self
.render_pixel_xor()
637 image
= self
.render_side_by_side()
639 image
= QtGui
.QPixmap()
640 self
.image
.pixmap
= image
643 zoom_mode
= self
.options
.zoom_mode
.currentIndex()
644 zoom_factor
= self
.options
.zoom_factors
[zoom_mode
][1]
645 if zoom_factor
> 0.0:
646 self
.image
.resetTransform()
647 self
.image
.scale(zoom_factor
, zoom_factor
)
648 poly
= self
.image
.mapToScene(self
.image
.viewport().rect())
649 self
.image
.last_scene_roi
= poly
.boundingRect()
651 def render_side_by_side(self
):
652 # Side-by-side lineup comp
653 pixmaps
= self
.pixmaps
654 width
= sum(pixmap
.width() for pixmap
in pixmaps
)
655 height
= max(pixmap
.height() for pixmap
in pixmaps
)
656 image
= create_image(width
, height
)
659 painter
= create_painter(image
)
661 for pixmap
in pixmaps
:
662 painter
.drawPixmap(x
, 0, pixmap
)
668 def render_comp(self
, comp_mode
):
669 # Get the max size to use as the render canvas
670 pixmaps
= self
.pixmaps
671 if len(pixmaps
) == 1:
674 width
= max(pixmap
.width() for pixmap
in pixmaps
)
675 height
= max(pixmap
.height() for pixmap
in pixmaps
)
676 image
= create_image(width
, height
)
678 painter
= create_painter(image
)
679 for pixmap
in (pixmaps
[0], pixmaps
[-1]):
680 x
= (width
- pixmap
.width()) // 2
681 y
= (height
- pixmap
.height()) // 2
682 painter
.drawPixmap(x
, y
, pixmap
)
683 painter
.setCompositionMode(comp_mode
)
688 def render_diff(self
):
689 comp_mode
= QtGui
.QPainter
.CompositionMode_Difference
690 return self
.render_comp(comp_mode
)
692 def render_xor(self
):
693 comp_mode
= QtGui
.QPainter
.CompositionMode_Xor
694 return self
.render_comp(comp_mode
)
696 def render_pixel_xor(self
):
697 comp_mode
= QtGui
.QPainter
.RasterOp_SourceXorDestination
698 return self
.render_comp(comp_mode
)
701 def create_image(width
, height
):
702 size
= QtCore
.QSize(width
, height
)
703 image
= QtGui
.QImage(size
, QtGui
.QImage
.Format_ARGB32_Premultiplied
)
704 image
.fill(Qt
.transparent
)
708 def text_matches(case_sensitive
, a
, b
):
709 """Compare text with case sensitivity taken into account"""
712 return a
.lower() == b
.lower()
715 def create_painter(image
):
716 painter
= QtGui
.QPainter(image
)
717 painter
.fillRect(image
.rect(), Qt
.transparent
)
721 class Options(QtWidgets
.QWidget
):
722 """Provide the options widget used by the editor
724 Actions are registered on the parent widget.
728 # mode combobox indexes
734 def __init__(self
, parent
):
735 super(Options
, self
).__init
__(parent
)
738 self
.ignore_space_at_eol
= self
.add_option(
739 N_('Ignore changes in whitespace at EOL')
742 self
.ignore_space_change
= self
.add_option(
743 N_('Ignore changes in amount of whitespace')
746 self
.ignore_all_space
= self
.add_option(N_('Ignore all whitespace'))
748 self
.function_context
= self
.add_option(
749 N_('Show whole surrounding functions of changes')
752 self
.show_line_numbers
= qtutils
.add_action_bool(
753 self
, N_('Show line numbers'), self
.set_line_numbers
, True
755 self
.enable_word_wrapping
= qtutils
.add_action_bool(
756 self
, N_('Enable word wrapping'), self
.set_word_wrapping
, True
759 self
.options
= qtutils
.create_action_button(
760 tooltip
=N_('Diff Options'), icon
=icons
.configure()
763 self
.toggle_image_diff
= qtutils
.create_action_button(
764 tooltip
=N_('Toggle image diff'), icon
=icons
.visualize()
766 self
.toggle_image_diff
.hide()
768 self
.image_mode
= qtutils
.combo(
769 [N_('Side by side'), N_('Diff'), N_('XOR'), N_('Pixel XOR')]
772 self
.zoom_factors
= (
773 (N_('Zoom to Fit'), 0.0),
781 zoom_modes
= [factor
[0] for factor
in self
.zoom_factors
]
782 self
.zoom_mode
= qtutils
.combo(zoom_modes
, parent
=self
)
784 self
.menu
= menu
= qtutils
.create_menu(N_('Diff Options'), self
.options
)
785 self
.options
.setMenu(menu
)
786 menu
.addAction(self
.ignore_space_at_eol
)
787 menu
.addAction(self
.ignore_space_change
)
788 menu
.addAction(self
.ignore_all_space
)
790 menu
.addAction(self
.function_context
)
791 menu
.addAction(self
.show_line_numbers
)
793 menu
.addAction(self
.enable_word_wrapping
)
796 layout
= qtutils
.hbox(
802 self
.toggle_image_diff
,
804 self
.setLayout(layout
)
807 self
.image_mode
.setFocusPolicy(Qt
.NoFocus
)
808 self
.zoom_mode
.setFocusPolicy(Qt
.NoFocus
)
809 self
.options
.setFocusPolicy(Qt
.NoFocus
)
810 self
.toggle_image_diff
.setFocusPolicy(Qt
.NoFocus
)
811 self
.setFocusPolicy(Qt
.NoFocus
)
813 def set_file_type(self
, file_type
):
814 """Set whether we are viewing an image file type"""
815 is_image
= file_type
== main
.Types
.IMAGE
816 self
.toggle_image_diff
.setVisible(is_image
)
818 def set_diff_type(self
, diff_type
):
819 """Toggle between image and text diffs"""
820 is_text
= diff_type
== main
.Types
.TEXT
821 is_image
= diff_type
== main
.Types
.IMAGE
822 self
.options
.setVisible(is_text
)
823 self
.image_mode
.setVisible(is_image
)
824 self
.zoom_mode
.setVisible(is_image
)
826 self
.toggle_image_diff
.setIcon(icons
.diff())
828 self
.toggle_image_diff
.setIcon(icons
.visualize())
830 def add_option(self
, title
):
831 """Add a diff option which calls update_options() on change"""
832 action
= qtutils
.add_action(self
, title
, self
.update_options
)
833 action
.setCheckable(True)
836 def update_options(self
):
837 """Update diff options in response to UI events"""
838 space_at_eol
= get(self
.ignore_space_at_eol
)
839 space_change
= get(self
.ignore_space_change
)
840 all_space
= get(self
.ignore_all_space
)
841 function_context
= get(self
.function_context
)
842 gitcmds
.update_diff_overrides(
843 space_at_eol
, space_change
, all_space
, function_context
845 self
.widget
.update_options()
847 def set_line_numbers(self
, value
):
848 """Enable / disable line numbers"""
849 self
.widget
.set_line_numbers(value
, update
=False)
851 def set_word_wrapping(self
, value
):
852 """Respond to Qt action callbacks"""
853 self
.widget
.set_word_wrapping(value
, update
=False)
855 def hide_advanced_options(self
):
856 """Hide advanced options that are not applicable to the DiffWidget"""
857 self
.show_line_numbers
.setVisible(False)
858 self
.ignore_space_at_eol
.setVisible(False)
859 self
.ignore_space_change
.setVisible(False)
860 self
.ignore_all_space
.setVisible(False)
861 self
.function_context
.setVisible(False)
864 # pylint: disable=too-many-ancestors
865 class DiffEditor(DiffTextEdit
):
869 options_changed
= Signal()
871 def __init__(self
, context
, options
, parent
):
872 DiffTextEdit
.__init
__(self
, context
, parent
, numbers
=True)
873 self
.context
= context
874 self
.model
= model
= context
.model
875 self
.selection_model
= selection_model
= context
.selection
877 # "Diff Options" tool menu
878 self
.options
= options
880 self
.action_apply_selection
= qtutils
.add_action(
883 self
.apply_selection
,
885 hotkeys
.STAGE_DIFF_ALT
,
888 self
.action_revert_selection
= qtutils
.add_action(
889 self
, 'Revert', self
.revert_selection
, hotkeys
.REVERT
891 self
.action_revert_selection
.setIcon(icons
.undo())
893 self
.action_edit_and_apply_selection
= qtutils
.add_action(
896 partial(self
.apply_selection
, edit
=True),
897 hotkeys
.EDIT_AND_STAGE_DIFF
,
900 self
.action_edit_and_revert_selection
= qtutils
.add_action(
903 partial(self
.revert_selection
, edit
=True),
904 hotkeys
.EDIT_AND_REVERT
,
906 self
.action_edit_and_revert_selection
.setIcon(icons
.undo())
907 self
.launch_editor
= actions
.launch_editor_at_line(
908 context
, self
, hotkeys
.EDIT_SHORT
, *hotkeys
.ACCEPT
910 self
.launch_difftool
= actions
.launch_difftool(context
, self
)
911 self
.stage_or_unstage
= actions
.stage_or_unstage(context
, self
)
913 # Emit up/down signals so that they can be routed by the main widget
914 self
.move_up
= actions
.move_up(self
)
915 self
.move_down
= actions
.move_down(self
)
917 model
.diff_text_updated
.connect(self
.set_diff
, type=Qt
.QueuedConnection
)
919 selection_model
.selection_changed
.connect(
920 self
.refresh
, type=Qt
.QueuedConnection
922 # Update the selection model when the cursor changes
923 self
.cursorPositionChanged
.connect(self
._update
_line
_number
)
925 qtutils
.connect_button(options
.toggle_image_diff
, self
.toggle_diff_type
)
927 def toggle_diff_type(self
):
928 cmds
.do(cmds
.ToggleDiffType
, self
.context
)
932 s
= self
.selection_model
.selection()
934 if model
.is_partially_stageable():
935 item
= s
.modified
[0] if s
.modified
else None
936 if item
in model
.submodules
:
938 elif item
not in model
.unstaged_deleted
:
940 self
.action_revert_selection
.setEnabled(enabled
)
942 def set_line_numbers(self
, enabled
, update
=False):
943 """Enable/disable the diff line number display"""
944 self
.numbers
.setVisible(enabled
)
946 with qtutils
.BlockSignals(self
.options
.show_line_numbers
):
947 self
.options
.show_line_numbers
.setChecked(enabled
)
948 # Refresh the display. Not doing this results in the display not
949 # correctly displaying the line numbers widget until the text scrolls.
950 self
.set_value(self
.value())
952 def update_options(self
):
953 self
.options_changed
.emit()
955 def create_context_menu(self
, event_pos
):
956 """Override create_context_menu() to display a completely custom menu"""
957 menu
= super(DiffEditor
, self
).create_context_menu(event_pos
)
958 context
= self
.context
960 s
= self
.selection_model
.selection()
961 filename
= self
.selection_model
.filename()
963 # These menu actions will be inserted at the start of the widget.
964 current_actions
= menu
.actions()
966 add_action
= menu_actions
.append
968 if model
.is_stageable() or model
.is_unstageable():
969 if model
.is_stageable():
970 self
.stage_or_unstage
.setText(N_('Stage'))
971 self
.stage_or_unstage
.setIcon(icons
.add())
973 self
.stage_or_unstage
.setText(N_('Unstage'))
974 self
.stage_or_unstage
.setIcon(icons
.remove())
975 add_action(self
.stage_or_unstage
)
977 if model
.is_partially_stageable():
978 item
= s
.modified
[0] if s
.modified
else None
979 if item
in model
.submodules
:
980 path
= core
.abspath(item
)
981 action
= qtutils
.add_action_with_icon(
985 cmds
.run(cmds
.Stage
, context
, s
.modified
),
986 hotkeys
.STAGE_SELECTION
,
990 action
= qtutils
.add_action_with_icon(
993 N_('Launch git-cola'),
994 cmds
.run(cmds
.OpenRepo
, context
, path
),
997 elif item
not in model
.unstaged_deleted
:
998 if self
.has_selection():
999 apply_text
= N_('Stage Selected Lines')
1000 edit_and_apply_text
= N_('Edit Selected Lines to Stage...')
1001 revert_text
= N_('Revert Selected Lines...')
1002 edit_and_revert_text
= N_('Edit Selected Lines to Revert...')
1004 apply_text
= N_('Stage Diff Hunk')
1005 edit_and_apply_text
= N_('Edit Diff Hunk to Stage...')
1006 revert_text
= N_('Revert Diff Hunk...')
1007 edit_and_revert_text
= N_('Edit Diff Hunk to Revert...')
1009 self
.action_apply_selection
.setText(apply_text
)
1010 self
.action_apply_selection
.setIcon(icons
.add())
1011 add_action(self
.action_apply_selection
)
1013 self
.action_edit_and_apply_selection
.setText(edit_and_apply_text
)
1014 self
.action_edit_and_apply_selection
.setIcon(icons
.add())
1015 add_action(self
.action_edit_and_apply_selection
)
1017 self
.action_revert_selection
.setText(revert_text
)
1018 add_action(self
.action_revert_selection
)
1020 self
.action_edit_and_revert_selection
.setText(edit_and_revert_text
)
1021 add_action(self
.action_edit_and_revert_selection
)
1023 if s
.staged
and model
.is_unstageable():
1025 if item
in model
.submodules
:
1026 path
= core
.abspath(item
)
1027 action
= qtutils
.add_action_with_icon(
1030 cmds
.Unstage
.name(),
1031 cmds
.run(cmds
.Unstage
, context
, s
.staged
),
1032 hotkeys
.STAGE_SELECTION
,
1036 qtutils
.add_action_with_icon(
1039 N_('Launch git-cola'),
1040 cmds
.run(cmds
.OpenRepo
, context
, path
),
1044 elif item
not in model
.staged_deleted
:
1045 if self
.has_selection():
1046 apply_text
= N_('Unstage Selected Lines')
1047 edit_and_apply_text
= N_('Edit Selected Lines to Unstage...')
1049 apply_text
= N_('Unstage Diff Hunk')
1050 edit_and_apply_text
= N_('Edit Diff Hunk to Unstage...')
1052 self
.action_apply_selection
.setText(apply_text
)
1053 self
.action_apply_selection
.setIcon(icons
.remove())
1054 add_action(self
.action_apply_selection
)
1056 self
.action_edit_and_apply_selection
.setText(edit_and_apply_text
)
1057 self
.action_edit_and_apply_selection
.setIcon(icons
.remove())
1058 add_action(self
.action_edit_and_apply_selection
)
1060 if model
.is_stageable() or model
.is_unstageable():
1061 # Do not show the "edit" action when the file does not exist.
1062 # Untracked files exist by definition.
1063 if filename
and core
.exists(filename
):
1064 add_action(qtutils
.menu_separator(menu
))
1065 add_action(self
.launch_editor
)
1067 # Removed files can still be diffed.
1068 add_action(self
.launch_difftool
)
1070 # Add the Previous/Next File actions, which improves discoverability
1071 # of their associated shortcuts
1072 add_action(qtutils
.menu_separator(menu
))
1073 add_action(self
.move_up
)
1074 add_action(self
.move_down
)
1075 add_action(qtutils
.menu_separator(menu
))
1078 first_action
= current_actions
[0]
1081 menu
.insertActions(first_action
, menu_actions
)
1085 def mousePressEvent(self
, event
):
1086 if event
.button() == Qt
.RightButton
:
1087 # Intercept right-click to move the cursor to the current position.
1088 # setTextCursor() clears the selection so this is only done when
1089 # nothing is selected.
1090 if not self
.has_selection():
1091 cursor
= self
.cursorForPosition(event
.pos())
1092 self
.setTextCursor(cursor
)
1094 return super(DiffEditor
, self
).mousePressEvent(event
)
1096 def setPlainText(self
, text
):
1097 """setPlainText(str) while retaining scrollbar positions"""
1100 highlight
= mode
not in (
1103 model
.mode_untracked
,
1105 self
.highlighter
.set_enabled(highlight
)
1107 scrollbar
= self
.verticalScrollBar()
1109 scrollvalue
= get(scrollbar
)
1116 DiffTextEdit
.setPlainText(self
, text
)
1118 if scrollbar
and scrollvalue
is not None:
1119 scrollbar
.setValue(scrollvalue
)
1121 def apply_selection(self
, *, edit
=False):
1123 s
= self
.selection_model
.single_selection()
1124 if model
.is_partially_stageable() and (s
.modified
or s
.untracked
):
1125 self
.process_diff_selection(edit
=edit
)
1126 elif model
.is_unstageable():
1127 self
.process_diff_selection(reverse
=True, edit
=edit
)
1129 def revert_selection(self
, *, edit
=False):
1130 """Destructively revert selected lines or hunk from a worktree file."""
1133 if self
.has_selection():
1134 title
= N_('Revert Selected Lines?')
1135 ok_text
= N_('Revert Selected Lines')
1137 title
= N_('Revert Diff Hunk?')
1138 ok_text
= N_('Revert Diff Hunk')
1140 if not Interaction
.confirm(
1143 'This operation drops uncommitted changes.\n'
1144 'These changes cannot be recovered.'
1146 N_('Revert the uncommitted changes?'),
1152 self
.process_diff_selection(reverse
=True, apply_to_worktree
=True, edit
=edit
)
1154 def extract_patch(self
, reverse
=False):
1155 first_line_idx
, last_line_idx
= self
.selected_lines()
1156 patch
= diffparse
.Patch
.parse(self
.model
.filename
, self
.model
.diff_text
)
1157 if self
.has_selection():
1158 return patch
.extract_subset(first_line_idx
, last_line_idx
, reverse
=reverse
)
1160 return patch
.extract_hunk(first_line_idx
, reverse
=reverse
)
1162 def patch_encoding(self
):
1163 if isinstance(self
.model
.diff_text
, core
.UStr
):
1164 # original encoding must prevail
1165 return self
.model
.diff_text
.encoding
1167 return self
.context
.cfg
.file_encoding(self
.model
.filename
)
1169 def process_diff_selection(
1170 self
, reverse
=False, apply_to_worktree
=False, edit
=False
1172 """Implement un/staging of the selected line(s) or hunk."""
1173 if self
.selection_model
.is_empty():
1175 patch
= self
.extract_patch(reverse
)
1176 if not patch
.has_changes():
1178 patch_encoding
= self
.patch_encoding()
1186 apply_to_worktree
=apply_to_worktree
,
1188 if not patch
.has_changes():
1199 def _update_line_number(self
):
1200 """Update the selection model when the cursor changes"""
1201 self
.selection_model
.line_number
= self
.numbers
.current_line()
1204 class DiffWidget(QtWidgets
.QWidget
):
1205 """Display commit metadata and text diffs"""
1207 def __init__(self
, context
, parent
, is_commit
=False, options
=None):
1208 QtWidgets
.QWidget
.__init
__(self
, parent
)
1210 self
.context
= context
1212 self
.oid_start
= None
1214 self
.options
= options
1216 author_font
= QtGui
.QFont(self
.font())
1217 author_font
.setPointSize(int(author_font
.pointSize() * 1.1))
1219 summary_font
= QtGui
.QFont(author_font
)
1220 summary_font
.setWeight(QtGui
.QFont
.Bold
)
1222 policy
= QtWidgets
.QSizePolicy(
1223 QtWidgets
.QSizePolicy
.MinimumExpanding
, QtWidgets
.QSizePolicy
.Minimum
1226 self
.gravatar_label
= gravatar
.GravatarLabel(self
.context
, parent
=self
)
1228 self
.author_label
= TextLabel()
1229 self
.author_label
.setTextFormat(Qt
.RichText
)
1230 self
.author_label
.setFont(author_font
)
1231 self
.author_label
.setSizePolicy(policy
)
1232 self
.author_label
.setAlignment(Qt
.AlignBottom
)
1233 self
.author_label
.elide()
1235 self
.date_label
= TextLabel()
1236 self
.date_label
.setTextFormat(Qt
.PlainText
)
1237 self
.date_label
.setSizePolicy(policy
)
1238 self
.date_label
.setAlignment(Qt
.AlignTop
)
1239 self
.date_label
.hide()
1241 self
.summary_label
= TextLabel()
1242 self
.summary_label
.setTextFormat(Qt
.PlainText
)
1243 self
.summary_label
.setFont(summary_font
)
1244 self
.summary_label
.setSizePolicy(policy
)
1245 self
.summary_label
.setAlignment(Qt
.AlignTop
)
1246 self
.summary_label
.elide()
1248 self
.oid_label
= TextLabel()
1249 self
.oid_label
.setTextFormat(Qt
.PlainText
)
1250 self
.oid_label
.setSizePolicy(policy
)
1251 self
.oid_label
.setAlignment(Qt
.AlignTop
)
1252 self
.oid_label
.elide()
1254 self
.diff
= DiffTextEdit(context
, self
, is_commit
=is_commit
, whitespace
=False)
1255 self
.setFocusProxy(self
.diff
)
1257 self
.info_layout
= qtutils
.vbox(
1266 self
.logo_layout
= qtutils
.hbox(
1267 defs
.no_margin
, defs
.button_spacing
, self
.gravatar_label
, self
.info_layout
1269 self
.logo_layout
.setContentsMargins(defs
.margin
, 0, defs
.margin
, 0)
1271 self
.main_layout
= qtutils
.vbox(
1272 defs
.no_margin
, defs
.spacing
, self
.logo_layout
, self
.diff
1274 self
.setLayout(self
.main_layout
)
1276 self
.set_tabwidth(prefs
.tabwidth(context
))
1278 def set_tabwidth(self
, width
):
1279 self
.diff
.set_tabwidth(width
)
1281 def set_word_wrapping(self
, enabled
, update
=False):
1282 """Enable and disable word wrapping"""
1283 self
.diff
.set_word_wrapping(enabled
, update
=update
)
1285 def set_options(self
, options
):
1286 """Register an options widget"""
1287 self
.options
= options
1288 self
.diff
.set_options(options
)
1290 def start_diff_task(self
, task
):
1291 """Clear the display and start a diff-gathering task"""
1292 self
.diff
.save_scrollbar()
1293 self
.diff
.set_loading_message()
1294 self
.context
.runtask
.start(task
, result
=self
.set_diff
)
1296 def set_diff_oid(self
, oid
, filename
=None):
1297 """Set the diff from a single commit object ID"""
1298 task
= DiffInfoTask(self
.context
, oid
, filename
)
1299 self
.start_diff_task(task
)
1301 def set_diff_range(self
, start
, end
, filename
=None):
1302 task
= DiffRangeTask(self
.context
, start
+ '~', end
, filename
)
1303 self
.start_diff_task(task
)
1305 def commits_selected(self
, commits
):
1306 """Display an appropriate diff when commits are selected"""
1310 commit
= commits
[-1]
1312 email
= commit
.email
or ''
1313 summary
= commit
.summary
or ''
1314 author
= commit
.author
or ''
1315 self
.set_details(oid
, author
, email
, '', summary
)
1318 if len(commits
) > 1:
1319 start
, end
= commits
[0], commits
[-1]
1320 self
.set_diff_range(start
.oid
, end
.oid
)
1321 self
.oid_start
= start
1324 self
.set_diff_oid(oid
)
1325 self
.oid_start
= None
1328 def set_diff(self
, diff
):
1329 """Set the diff text"""
1330 self
.diff
.set_diff(diff
)
1332 def set_details(self
, oid
, author
, email
, date
, summary
):
1333 template_args
= {'author': author
, 'email': email
, 'summary': summary
}
1335 """%(author)s <"""
1336 """<a href="mailto:%(email)s">"""
1337 """%(email)s</a>>""" % template_args
1339 author_template
= '%(author)s <%(email)s>' % template_args
1341 self
.date_label
.set_text(date
)
1342 self
.date_label
.setVisible(bool(date
))
1343 self
.oid_label
.set_text(oid
)
1344 self
.author_label
.set_template(author_text
, author_template
)
1345 self
.summary_label
.set_text(summary
)
1346 self
.gravatar_label
.set_email(email
)
1349 self
.date_label
.set_text('')
1350 self
.oid_label
.set_text('')
1351 self
.author_label
.set_text('')
1352 self
.summary_label
.set_text('')
1353 self
.gravatar_label
.clear()
1356 def files_selected(self
, filenames
):
1357 """Update the view when a filename is selected"""
1360 oid_start
= self
.oid_start
1361 oid_end
= self
.oid_end
1362 if oid_start
and oid_end
:
1363 self
.set_diff_range(oid_start
.oid
, oid_end
.oid
, filename
=filenames
[0])
1365 self
.set_diff_oid(self
.oid
, filename
=filenames
[0])
1368 class TextLabel(QtWidgets
.QLabel
):
1369 def __init__(self
, parent
=None):
1370 QtWidgets
.QLabel
.__init
__(self
, parent
)
1371 self
.setTextInteractionFlags(
1372 Qt
.TextSelectableByMouse | Qt
.LinksAccessibleByMouse
1378 self
._metrics
= QtGui
.QFontMetrics(self
.font())
1379 self
.setOpenExternalLinks(True)
1384 def set_text(self
, text
):
1385 self
.set_template(text
, text
)
1387 def set_template(self
, text
, template
):
1388 self
._display
= text
1390 self
._template
= template
1391 self
.update_text(self
.width())
1392 self
.setText(self
._display
)
1394 def update_text(self
, width
):
1395 self
._display
= self
._text
1398 text
= self
._metrics
.elidedText(self
._template
, Qt
.ElideRight
, width
- 2)
1399 if text
!= self
._template
:
1400 self
._display
= text
1403 def setFont(self
, font
):
1404 self
._metrics
= QtGui
.QFontMetrics(font
)
1405 QtWidgets
.QLabel
.setFont(self
, font
)
1407 def resizeEvent(self
, event
):
1409 self
.update_text(event
.size().width())
1410 with qtutils
.BlockSignals(self
):
1411 self
.setText(self
._display
)
1412 QtWidgets
.QLabel
.resizeEvent(self
, event
)
1415 class DiffInfoTask(qtutils
.Task
):
1416 """Gather diffs for a single commit"""
1418 def __init__(self
, context
, oid
, filename
):
1419 qtutils
.Task
.__init
__(self
)
1420 self
.context
= context
1422 self
.filename
= filename
1425 context
= self
.context
1427 return gitcmds
.diff_info(context
, oid
, filename
=self
.filename
)
1430 class DiffRangeTask(qtutils
.Task
):
1431 """Gather diffs for a range of commits"""
1433 def __init__(self
, context
, start
, end
, filename
):
1434 qtutils
.Task
.__init
__(self
)
1435 self
.context
= context
1438 self
.filename
= filename
1441 context
= self
.context
1442 return gitcmds
.diff_range(context
, self
.start
, self
.end
, filename
=self
.filename
)