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 """Highlight the current text block"""
88 if not self
.enabled
or not text
:
91 state
= self
.get_next_state(text
)
92 if state
== self
.DIFFSTAT_STATE
:
93 state
, formats
= self
.get_formats_for_diffstat(state
, text
)
94 elif state
== self
.DIFF_FILE_HEADER_STATE
:
95 state
, formats
= self
.get_formats_for_diff_header(state
, text
)
96 elif state
== self
.DIFF_STATE
:
97 state
, formats
= self
.get_formats_for_diff_text(state
, text
)
99 for start
, end
, fmt
in formats
:
100 self
.setFormat(start
, end
, fmt
)
102 self
.setCurrentBlockState(state
)
104 def get_next_state(self
, text
):
105 """Transition to the next state based on the input text"""
106 state
= self
.previousBlockState()
107 if state
== DiffSyntaxHighlighter
.INITIAL_STATE
:
108 if text
.startswith('Submodule '):
109 state
= DiffSyntaxHighlighter
.SUBMODULE_STATE
110 elif text
.startswith('diff --git '):
111 state
= DiffSyntaxHighlighter
.DIFFSTAT_STATE
113 state
= DiffSyntaxHighlighter
.DEFAULT_STATE
115 state
= DiffSyntaxHighlighter
.DIFFSTAT_STATE
119 def get_formats_for_diffstat(self
, state
, text
):
120 """Returns (state, [(start, end, fmt), ...]) for highlighting diffstat text"""
122 if self
.DIFF_FILE_HEADER_START_RGX
.match(text
):
123 state
= self
.DIFF_FILE_HEADER_STATE
125 fmt
= self
.diff_header_fmt
126 formats
.append((0, end
, fmt
))
127 elif self
.DIFF_HUNK_HEADER_RGX
.match(text
):
128 state
= self
.DIFF_STATE
130 fmt
= self
.bold_diff_header_fmt
131 formats
.append((0, end
, fmt
))
133 offset
= text
.index('|')
134 formats
.append((0, offset
, self
.bold_diff_header_fmt
))
135 formats
.append((offset
, len(text
) - offset
, self
.diff_header_fmt
))
137 formats
.append((0, len(text
), self
.diff_header_fmt
))
139 return state
, formats
141 def get_formats_for_diff_header(self
, state
, text
):
142 """Returns (state, [(start, end, fmt), ...]) for highlighting diff headers"""
144 if self
.DIFF_HUNK_HEADER_RGX
.match(text
):
145 state
= self
.DIFF_STATE
146 formats
.append((0, len(text
), self
.bold_diff_header_fmt
))
148 formats
.append((0, len(text
), self
.diff_header_fmt
))
150 return state
, formats
152 def get_formats_for_diff_text(self
, state
, text
):
153 """Return (state, [(start, end fmt), ...]) for highlighting diff text"""
156 if self
.DIFF_FILE_HEADER_START_RGX
.match(text
):
157 state
= self
.DIFF_FILE_HEADER_STATE
158 formats
.append((0, len(text
), self
.diff_header_fmt
))
160 elif self
.DIFF_HUNK_HEADER_RGX
.match(text
):
161 formats
.append((0, len(text
), self
.bold_diff_header_fmt
))
163 elif text
.startswith('-'):
165 state
= self
.END_STATE
167 formats
.append((0, len(text
), self
.diff_remove_fmt
))
169 elif text
.startswith('+'):
170 formats
.append((0, len(text
), self
.diff_add_fmt
))
172 match
= self
.BAD_WHITESPACE_RGX
.search(text
)
173 if match
is not None:
174 start
= match
.start()
175 formats
.append((start
, len(text
) - start
, self
.bad_whitespace_fmt
))
177 return state
, formats
180 # pylint: disable=too-many-ancestors
181 class DiffTextEdit(VimHintedPlainTextEdit
):
182 """A textedit for interacting with diff text"""
185 self
, context
, parent
, is_commit
=False, whitespace
=True, numbers
=False
187 VimHintedPlainTextEdit
.__init
__(self
, context
, '', parent
=parent
)
188 # Diff/patch syntax highlighter
189 self
.highlighter
= DiffSyntaxHighlighter(
190 context
, self
.document(), is_commit
=is_commit
, whitespace
=whitespace
193 self
.numbers
= DiffLineNumbers(context
, self
)
197 self
.scrollvalue
= None
199 self
.copy_diff_action
= qtutils
.add_action(
205 self
.copy_diff_action
.setIcon(icons
.copy())
206 self
.copy_diff_action
.setEnabled(False)
207 self
.menu_actions
.append(self
.copy_diff_action
)
209 # pylint: disable=no-member
210 self
.cursorPositionChanged
.connect(self
._cursor
_changed
, Qt
.QueuedConnection
)
211 self
.selectionChanged
.connect(self
._selection
_changed
, Qt
.QueuedConnection
)
213 def setFont(self
, font
):
214 """Override setFont() so that we can use a custom "block" cursor"""
215 super(DiffTextEdit
, self
).setFont(font
)
216 if prefs
.block_cursor(self
.context
):
217 metrics
= QtGui
.QFontMetrics(font
)
218 width
= metrics
.width('M')
219 self
.setCursorWidth(width
)
221 def _cursor_changed(self
):
222 """Update the line number display when the cursor changes"""
223 line_number
= max(0, self
.textCursor().blockNumber())
225 self
.numbers
.set_highlighted(line_number
)
227 def _selection_changed(self
):
228 """Respond to selection changes"""
229 selected
= bool(self
.selected_text())
230 self
.copy_diff_action
.setEnabled(selected
)
232 def resizeEvent(self
, event
):
233 super(DiffTextEdit
, self
).resizeEvent(event
)
235 self
.numbers
.refresh_size()
237 def save_scrollbar(self
):
238 """Save the scrollbar value, but only on the first call"""
239 if self
.scrollvalue
is None:
240 scrollbar
= self
.verticalScrollBar()
242 scrollvalue
= get(scrollbar
)
245 self
.scrollvalue
= scrollvalue
247 def restore_scrollbar(self
):
248 """Restore the scrollbar and clear state"""
249 scrollbar
= self
.verticalScrollBar()
250 scrollvalue
= self
.scrollvalue
251 if scrollbar
and scrollvalue
is not None:
252 scrollbar
.setValue(scrollvalue
)
253 self
.scrollvalue
= None
255 def set_loading_message(self
):
256 """Add a pending loading message in the diff view"""
257 self
.hint
.set_value('+++ ' + N_('Loading...'))
260 def set_diff(self
, diff
):
261 """Set the diff text, but save the scrollbar"""
262 diff
= diff
.rstrip('\n') # diffs include two empty newlines
263 self
.save_scrollbar()
265 self
.hint
.set_value('')
267 self
.numbers
.set_diff(diff
)
270 self
.restore_scrollbar()
272 def selected_diff_stripped(self
):
273 """Return the selected diff stripped of any diff characters"""
274 sep
, selection
= self
.selected_text_lines()
275 return sep
.join(_strip_diff(line
) for line
in selection
)
278 """Copy the selected diff text stripped of any diff prefix characters"""
279 text
= self
.selected_diff_stripped()
280 qtutils
.set_clipboard(text
)
282 def selected_lines(self
):
283 """Return selected lines"""
284 cursor
= self
.textCursor()
285 selection_start
= cursor
.selectionStart()
286 selection_end
= max(selection_start
, cursor
.selectionEnd() - 1)
293 for line_idx
, line
in enumerate(get(self
, default
='').splitlines()):
294 line_end
= line_start
+ len(line
)
295 if line_start
<= selection_start
<= line_end
:
296 first_line_idx
= line_idx
297 if line_start
<= selection_end
<= line_end
:
298 last_line_idx
= line_idx
300 line_start
= line_end
+ 1
302 if first_line_idx
== -1:
303 first_line_idx
= line_idx
305 if last_line_idx
== -1:
306 last_line_idx
= line_idx
308 return first_line_idx
, last_line_idx
310 def selected_text_lines(self
):
311 """Return selected lines and the CRLF / LF separator"""
312 first_line_idx
, last_line_idx
= self
.selected_lines()
313 text
= get(self
, default
='')
316 for line_idx
, line
in enumerate(text
.split(sep
)):
317 if first_line_idx
<= line_idx
<= last_line_idx
:
323 """Return either CRLF or LF based on the content"""
331 def _strip_diff(value
):
332 """Remove +/-/<space> from a selection"""
333 if value
.startswith(('+', '-', ' ')):
338 class DiffLineNumbers(TextDecorator
):
339 def __init__(self
, context
, parent
):
340 TextDecorator
.__init
__(self
, parent
)
341 self
.highlight_line
= -1
343 self
.parser
= diffparse
.DiffLines()
344 self
.formatter
= diffparse
.FormatDigits()
346 self
.setFont(qtutils
.diff_font(context
))
347 self
._char
_width
= self
.fontMetrics().width('0')
349 QPalette
= QtGui
.QPalette
350 self
._palette
= palette
= self
.palette()
351 self
._base
= palette
.color(QtGui
.QPalette
.Base
)
352 self
._highlight
= palette
.color(QPalette
.Highlight
)
353 self
._highlight
.setAlphaF(0.3)
354 self
._highlight
_text
= palette
.color(QPalette
.HighlightedText
)
355 self
._window
= palette
.color(QPalette
.Window
)
356 self
._disabled
= palette
.color(QPalette
.Disabled
, QPalette
.Text
)
358 def set_diff(self
, diff
):
359 self
.lines
= self
.parser
.parse(diff
)
360 self
.formatter
.set_digits(self
.parser
.digits())
362 def width_hint(self
):
363 if not self
.isVisible():
369 extra
= 3 # one space in-between, one space after
372 extra
= 2 # one space in-between, one space after
374 digits
= parser
.digits() * columns
376 return defs
.margin
+ (self
._char
_width
* (digits
+ extra
))
378 def set_highlighted(self
, line_number
):
379 """Set the line to highlight"""
380 self
.highlight_line
= line_number
382 def current_line(self
):
384 if lines
and self
.highlight_line
>= 0:
385 # Find the next valid line
386 for i
in range(self
.highlight_line
, len(lines
)):
387 # take the "new" line number: last value in tuple
388 line_number
= lines
[i
][-1]
392 # Find the previous valid line
393 for i
in range(self
.highlight_line
- 1, -1, -1):
394 # take the "new" line number: last value in tuple
396 line_number
= lines
[i
][-1]
401 def paintEvent(self
, event
):
402 """Paint the line number"""
406 painter
= QtGui
.QPainter(self
)
407 painter
.fillRect(event
.rect(), self
._base
)
410 content_offset
= editor
.contentOffset()
411 block
= editor
.firstVisibleBlock()
413 text_width
= width
- (defs
.margin
* 2)
414 text_flags
= Qt
.AlignRight | Qt
.AlignVCenter
415 event_rect_bottom
= event
.rect().bottom()
417 highlight_line
= self
.highlight_line
418 highlight
= self
._highlight
419 highlight_text
= self
._highlight
_text
420 disabled
= self
._disabled
424 num_lines
= len(lines
)
426 while block
.isValid():
427 block_number
= block
.blockNumber()
428 if block_number
>= num_lines
:
430 block_geom
= editor
.blockBoundingGeometry(block
)
431 rect
= block_geom
.translated(content_offset
).toRect()
432 if not block
.isVisible() or rect
.top() >= event_rect_bottom
:
435 if block_number
== highlight_line
:
436 painter
.fillRect(rect
.x(), rect
.y(), width
, rect
.height(), highlight
)
437 painter
.setPen(highlight_text
)
439 painter
.setPen(disabled
)
441 line
= lines
[block_number
]
444 text
= fmt
.value(a
, b
)
446 old
, base
, new
= line
447 text
= fmt
.merge_value(old
, base
, new
)
458 block
= block
.next() # pylint: disable=next-method-called
461 class Viewer(QtWidgets
.QFrame
):
462 """Text and image diff viewers"""
466 def __init__(self
, context
, parent
=None):
467 super(Viewer
, self
).__init
__(parent
)
469 self
.context
= context
470 self
.model
= model
= context
.model
473 self
.options
= options
= Options(self
)
474 self
.text
= DiffEditor(context
, options
, self
)
475 self
.image
= imageview
.ImageView(parent
=self
)
476 self
.image
.setFocusPolicy(Qt
.NoFocus
)
477 self
.search_widget
= TextSearchWidget(self
)
478 self
.search_widget
.hide()
480 stack
= self
.stack
= QtWidgets
.QStackedWidget(self
)
481 stack
.addWidget(self
.text
)
482 stack
.addWidget(self
.image
)
484 self
.main_layout
= qtutils
.vbox(
490 self
.setLayout(self
.main_layout
)
493 model
.images_changed
.connect(self
.set_images
, type=Qt
.QueuedConnection
)
495 # Observe the diff type
496 model
.diff_type_changed
.connect(self
.set_diff_type
, type=Qt
.QueuedConnection
)
498 # Observe the file type
499 model
.file_type_changed
.connect(self
.set_file_type
, type=Qt
.QueuedConnection
)
501 # Observe the image mode combo box
502 options
.image_mode
.currentIndexChanged
.connect(lambda _
: self
.render())
503 options
.zoom_mode
.currentIndexChanged
.connect(lambda _
: self
.render())
505 self
.setFocusProxy(self
.text
)
507 self
.search_widget
.search_text
.connect(self
.search_text
)
509 self
.search_action
= qtutils
.add_action(
511 N_('Search in Diff'),
512 self
.show_search_diff
,
515 self
.search_next_action
= qtutils
.add_action(
517 N_('Find next item'),
518 self
.search_widget
.search
,
521 self
.search_prev_action
= qtutils
.add_action(
523 N_('Find previous item'),
524 self
.search_widget
.search_backwards
,
528 def show_search_diff(self
):
529 """Show a dialog for searching diffs"""
530 # The diff search is only active in text mode.
531 if self
.stack
.currentIndex() != self
.INDEX_TEXT
:
533 if not self
.search_widget
.isVisible():
534 self
.search_widget
.show()
535 self
.search_widget
.setFocus(True)
537 def search_text(self
, text
, backwards
):
538 """Search the diff text for the given text"""
539 cursor
= self
.text
.textCursor()
540 if cursor
.hasSelection():
541 selected_text
= cursor
.selectedText()
542 case_sensitive
= self
.search_widget
.is_case_sensitive()
543 if text_matches(case_sensitive
, selected_text
, text
):
545 position
= cursor
.selectionStart()
547 position
= cursor
.selectionEnd()
550 position
= cursor
.selectionEnd()
552 position
= cursor
.selectionStart()
553 cursor
.setPosition(position
)
554 self
.text
.setTextCursor(cursor
)
556 flags
= self
.search_widget
.find_flags(backwards
)
557 if not self
.text
.find(text
, flags
):
559 location
= QtGui
.QTextCursor
.End
561 location
= QtGui
.QTextCursor
.Start
562 cursor
.movePosition(location
, QtGui
.QTextCursor
.MoveAnchor
)
563 self
.text
.setTextCursor(cursor
)
564 self
.text
.find(text
, flags
)
566 def export_state(self
, state
):
567 state
['show_diff_line_numbers'] = self
.options
.show_line_numbers
.isChecked()
568 state
['image_diff_mode'] = self
.options
.image_mode
.currentIndex()
569 state
['image_zoom_mode'] = self
.options
.zoom_mode
.currentIndex()
570 state
['word_wrap'] = self
.options
.enable_word_wrapping
.isChecked()
573 def apply_state(self
, state
):
574 diff_numbers
= bool(state
.get('show_diff_line_numbers', False))
575 self
.set_line_numbers(diff_numbers
, update
=True)
577 image_mode
= utils
.asint(state
.get('image_diff_mode', 0))
578 self
.options
.image_mode
.set_index(image_mode
)
580 zoom_mode
= utils
.asint(state
.get('image_zoom_mode', 0))
581 self
.options
.zoom_mode
.set_index(zoom_mode
)
583 word_wrap
= bool(state
.get('word_wrap', True))
584 self
.set_word_wrapping(word_wrap
, update
=True)
587 def set_diff_type(self
, diff_type
):
588 """Manage the image and text diff views when selection changes"""
589 # The "diff type" is whether the diff viewer is displaying an image.
590 self
.options
.set_diff_type(diff_type
)
591 if diff_type
== main
.Types
.IMAGE
:
592 self
.stack
.setCurrentWidget(self
.image
)
593 self
.search_widget
.hide()
596 self
.stack
.setCurrentWidget(self
.text
)
598 def set_file_type(self
, file_type
):
599 """Manage the diff options when the file type changes"""
600 # The "file type" is whether the file itself is an image.
601 self
.options
.set_file_type(file_type
)
603 def update_options(self
):
604 """Emit a signal indicating that options have changed"""
605 self
.text
.update_options()
607 def set_line_numbers(self
, enabled
, update
=False):
608 """Enable/disable line numbers in the text widget"""
609 self
.text
.set_line_numbers(enabled
, update
=update
)
611 def set_word_wrapping(self
, enabled
, update
=False):
612 """Enable/disable word wrapping in the text widget"""
613 self
.text
.set_word_wrapping(enabled
, update
=update
)
616 self
.image
.pixmap
= QtGui
.QPixmap()
620 for (image
, unlink
) in self
.images
:
621 if unlink
and core
.exists(image
):
625 def set_images(self
, images
):
632 # In order to comp, we first have to load all the images
633 all_pixmaps
= [QtGui
.QPixmap(image
[0]) for image
in images
]
634 pixmaps
= [pixmap
for pixmap
in all_pixmaps
if not pixmap
.isNull()]
639 self
.pixmaps
= pixmaps
647 mode
= self
.options
.image_mode
.currentIndex()
648 if mode
== self
.options
.SIDE_BY_SIDE
:
649 image
= self
.render_side_by_side()
650 elif mode
== self
.options
.DIFF
:
651 image
= self
.render_diff()
652 elif mode
== self
.options
.XOR
:
653 image
= self
.render_xor()
654 elif mode
== self
.options
.PIXEL_XOR
:
655 image
= self
.render_pixel_xor()
657 image
= self
.render_side_by_side()
659 image
= QtGui
.QPixmap()
660 self
.image
.pixmap
= image
663 zoom_mode
= self
.options
.zoom_mode
.currentIndex()
664 zoom_factor
= self
.options
.zoom_factors
[zoom_mode
][1]
665 if zoom_factor
> 0.0:
666 self
.image
.resetTransform()
667 self
.image
.scale(zoom_factor
, zoom_factor
)
668 poly
= self
.image
.mapToScene(self
.image
.viewport().rect())
669 self
.image
.last_scene_roi
= poly
.boundingRect()
671 def render_side_by_side(self
):
672 # Side-by-side lineup comp
673 pixmaps
= self
.pixmaps
674 width
= sum(pixmap
.width() for pixmap
in pixmaps
)
675 height
= max(pixmap
.height() for pixmap
in pixmaps
)
676 image
= create_image(width
, height
)
679 painter
= create_painter(image
)
681 for pixmap
in pixmaps
:
682 painter
.drawPixmap(x
, 0, pixmap
)
688 def render_comp(self
, comp_mode
):
689 # Get the max size to use as the render canvas
690 pixmaps
= self
.pixmaps
691 if len(pixmaps
) == 1:
694 width
= max(pixmap
.width() for pixmap
in pixmaps
)
695 height
= max(pixmap
.height() for pixmap
in pixmaps
)
696 image
= create_image(width
, height
)
698 painter
= create_painter(image
)
699 for pixmap
in (pixmaps
[0], pixmaps
[-1]):
700 x
= (width
- pixmap
.width()) // 2
701 y
= (height
- pixmap
.height()) // 2
702 painter
.drawPixmap(x
, y
, pixmap
)
703 painter
.setCompositionMode(comp_mode
)
708 def render_diff(self
):
709 comp_mode
= QtGui
.QPainter
.CompositionMode_Difference
710 return self
.render_comp(comp_mode
)
712 def render_xor(self
):
713 comp_mode
= QtGui
.QPainter
.CompositionMode_Xor
714 return self
.render_comp(comp_mode
)
716 def render_pixel_xor(self
):
717 comp_mode
= QtGui
.QPainter
.RasterOp_SourceXorDestination
718 return self
.render_comp(comp_mode
)
721 def create_image(width
, height
):
722 size
= QtCore
.QSize(width
, height
)
723 image
= QtGui
.QImage(size
, QtGui
.QImage
.Format_ARGB32_Premultiplied
)
724 image
.fill(Qt
.transparent
)
728 def text_matches(case_sensitive
, a
, b
):
729 """Compare text with case sensitivity taken into account"""
732 return a
.lower() == b
.lower()
735 def create_painter(image
):
736 painter
= QtGui
.QPainter(image
)
737 painter
.fillRect(image
.rect(), Qt
.transparent
)
741 class Options(QtWidgets
.QWidget
):
742 """Provide the options widget used by the editor
744 Actions are registered on the parent widget.
748 # mode combobox indexes
754 def __init__(self
, parent
):
755 super(Options
, self
).__init
__(parent
)
758 self
.ignore_space_at_eol
= self
.add_option(
759 N_('Ignore changes in whitespace at EOL')
762 self
.ignore_space_change
= self
.add_option(
763 N_('Ignore changes in amount of whitespace')
766 self
.ignore_all_space
= self
.add_option(N_('Ignore all whitespace'))
768 self
.function_context
= self
.add_option(
769 N_('Show whole surrounding functions of changes')
772 self
.show_line_numbers
= qtutils
.add_action_bool(
773 self
, N_('Show line numbers'), self
.set_line_numbers
, True
775 self
.enable_word_wrapping
= qtutils
.add_action_bool(
776 self
, N_('Enable word wrapping'), self
.set_word_wrapping
, True
779 self
.options
= qtutils
.create_action_button(
780 tooltip
=N_('Diff Options'), icon
=icons
.configure()
783 self
.toggle_image_diff
= qtutils
.create_action_button(
784 tooltip
=N_('Toggle image diff'), icon
=icons
.visualize()
786 self
.toggle_image_diff
.hide()
788 self
.image_mode
= qtutils
.combo(
789 [N_('Side by side'), N_('Diff'), N_('XOR'), N_('Pixel XOR')]
792 self
.zoom_factors
= (
793 (N_('Zoom to Fit'), 0.0),
801 zoom_modes
= [factor
[0] for factor
in self
.zoom_factors
]
802 self
.zoom_mode
= qtutils
.combo(zoom_modes
, parent
=self
)
804 self
.menu
= menu
= qtutils
.create_menu(N_('Diff Options'), self
.options
)
805 self
.options
.setMenu(menu
)
806 menu
.addAction(self
.ignore_space_at_eol
)
807 menu
.addAction(self
.ignore_space_change
)
808 menu
.addAction(self
.ignore_all_space
)
810 menu
.addAction(self
.function_context
)
811 menu
.addAction(self
.show_line_numbers
)
813 menu
.addAction(self
.enable_word_wrapping
)
816 layout
= qtutils
.hbox(
822 self
.toggle_image_diff
,
824 self
.setLayout(layout
)
827 self
.image_mode
.setFocusPolicy(Qt
.NoFocus
)
828 self
.zoom_mode
.setFocusPolicy(Qt
.NoFocus
)
829 self
.options
.setFocusPolicy(Qt
.NoFocus
)
830 self
.toggle_image_diff
.setFocusPolicy(Qt
.NoFocus
)
831 self
.setFocusPolicy(Qt
.NoFocus
)
833 def set_file_type(self
, file_type
):
834 """Set whether we are viewing an image file type"""
835 is_image
= file_type
== main
.Types
.IMAGE
836 self
.toggle_image_diff
.setVisible(is_image
)
838 def set_diff_type(self
, diff_type
):
839 """Toggle between image and text diffs"""
840 is_text
= diff_type
== main
.Types
.TEXT
841 is_image
= diff_type
== main
.Types
.IMAGE
842 self
.options
.setVisible(is_text
)
843 self
.image_mode
.setVisible(is_image
)
844 self
.zoom_mode
.setVisible(is_image
)
846 self
.toggle_image_diff
.setIcon(icons
.diff())
848 self
.toggle_image_diff
.setIcon(icons
.visualize())
850 def add_option(self
, title
):
851 """Add a diff option which calls update_options() on change"""
852 action
= qtutils
.add_action(self
, title
, self
.update_options
)
853 action
.setCheckable(True)
856 def update_options(self
):
857 """Update diff options in response to UI events"""
858 space_at_eol
= get(self
.ignore_space_at_eol
)
859 space_change
= get(self
.ignore_space_change
)
860 all_space
= get(self
.ignore_all_space
)
861 function_context
= get(self
.function_context
)
862 gitcmds
.update_diff_overrides(
863 space_at_eol
, space_change
, all_space
, function_context
865 self
.widget
.update_options()
867 def set_line_numbers(self
, value
):
868 """Enable / disable line numbers"""
869 self
.widget
.set_line_numbers(value
, update
=False)
871 def set_word_wrapping(self
, value
):
872 """Respond to Qt action callbacks"""
873 self
.widget
.set_word_wrapping(value
, update
=False)
875 def hide_advanced_options(self
):
876 """Hide advanced options that are not applicable to the DiffWidget"""
877 self
.show_line_numbers
.setVisible(False)
878 self
.ignore_space_at_eol
.setVisible(False)
879 self
.ignore_space_change
.setVisible(False)
880 self
.ignore_all_space
.setVisible(False)
881 self
.function_context
.setVisible(False)
884 # pylint: disable=too-many-ancestors
885 class DiffEditor(DiffTextEdit
):
889 options_changed
= Signal()
891 def __init__(self
, context
, options
, parent
):
892 DiffTextEdit
.__init
__(self
, context
, parent
, numbers
=True)
893 self
.context
= context
894 self
.model
= model
= context
.model
895 self
.selection_model
= selection_model
= context
.selection
897 # "Diff Options" tool menu
898 self
.options
= options
900 self
.action_apply_selection
= qtutils
.add_action(
903 self
.apply_selection
,
905 hotkeys
.STAGE_DIFF_ALT
,
908 self
.action_revert_selection
= qtutils
.add_action(
909 self
, 'Revert', self
.revert_selection
, hotkeys
.REVERT
911 self
.action_revert_selection
.setIcon(icons
.undo())
913 self
.action_edit_and_apply_selection
= qtutils
.add_action(
916 partial(self
.apply_selection
, edit
=True),
917 hotkeys
.EDIT_AND_STAGE_DIFF
,
920 self
.action_edit_and_revert_selection
= qtutils
.add_action(
923 partial(self
.revert_selection
, edit
=True),
924 hotkeys
.EDIT_AND_REVERT
,
926 self
.action_edit_and_revert_selection
.setIcon(icons
.undo())
927 self
.launch_editor
= actions
.launch_editor_at_line(
928 context
, self
, hotkeys
.EDIT_SHORT
, *hotkeys
.ACCEPT
930 self
.launch_difftool
= actions
.launch_difftool(context
, self
)
931 self
.stage_or_unstage
= actions
.stage_or_unstage(context
, self
)
933 # Emit up/down signals so that they can be routed by the main widget
934 self
.move_up
= actions
.move_up(self
)
935 self
.move_down
= actions
.move_down(self
)
937 model
.diff_text_updated
.connect(self
.set_diff
, type=Qt
.QueuedConnection
)
939 selection_model
.selection_changed
.connect(
940 self
.refresh
, type=Qt
.QueuedConnection
942 # Update the selection model when the cursor changes
943 self
.cursorPositionChanged
.connect(self
._update
_line
_number
)
945 qtutils
.connect_button(options
.toggle_image_diff
, self
.toggle_diff_type
)
947 def toggle_diff_type(self
):
948 cmds
.do(cmds
.ToggleDiffType
, self
.context
)
952 s
= self
.selection_model
.selection()
954 if model
.is_partially_stageable():
955 item
= s
.modified
[0] if s
.modified
else None
956 if item
in model
.submodules
:
958 elif item
not in model
.unstaged_deleted
:
960 self
.action_revert_selection
.setEnabled(enabled
)
962 def set_line_numbers(self
, enabled
, update
=False):
963 """Enable/disable the diff line number display"""
964 self
.numbers
.setVisible(enabled
)
966 with qtutils
.BlockSignals(self
.options
.show_line_numbers
):
967 self
.options
.show_line_numbers
.setChecked(enabled
)
968 # Refresh the display. Not doing this results in the display not
969 # correctly displaying the line numbers widget until the text scrolls.
970 self
.set_value(self
.value())
972 def update_options(self
):
973 self
.options_changed
.emit()
975 def create_context_menu(self
, event_pos
):
976 """Override create_context_menu() to display a completely custom menu"""
977 menu
= super(DiffEditor
, self
).create_context_menu(event_pos
)
978 context
= self
.context
980 s
= self
.selection_model
.selection()
981 filename
= self
.selection_model
.filename()
983 # These menu actions will be inserted at the start of the widget.
984 current_actions
= menu
.actions()
986 add_action
= menu_actions
.append
988 if model
.is_stageable() or model
.is_unstageable():
989 if model
.is_stageable():
990 self
.stage_or_unstage
.setText(N_('Stage'))
991 self
.stage_or_unstage
.setIcon(icons
.add())
993 self
.stage_or_unstage
.setText(N_('Unstage'))
994 self
.stage_or_unstage
.setIcon(icons
.remove())
995 add_action(self
.stage_or_unstage
)
997 if model
.is_partially_stageable():
998 item
= s
.modified
[0] if s
.modified
else None
999 if item
in model
.submodules
:
1000 path
= core
.abspath(item
)
1001 action
= qtutils
.add_action_with_icon(
1005 cmds
.run(cmds
.Stage
, context
, s
.modified
),
1006 hotkeys
.STAGE_SELECTION
,
1010 action
= qtutils
.add_action_with_icon(
1013 N_('Launch git-cola'),
1014 cmds
.run(cmds
.OpenRepo
, context
, path
),
1017 elif item
not in model
.unstaged_deleted
:
1018 if self
.has_selection():
1019 apply_text
= N_('Stage Selected Lines')
1020 edit_and_apply_text
= N_('Edit Selected Lines to Stage...')
1021 revert_text
= N_('Revert Selected Lines...')
1022 edit_and_revert_text
= N_('Edit Selected Lines to Revert...')
1024 apply_text
= N_('Stage Diff Hunk')
1025 edit_and_apply_text
= N_('Edit Diff Hunk to Stage...')
1026 revert_text
= N_('Revert Diff Hunk...')
1027 edit_and_revert_text
= N_('Edit Diff Hunk to Revert...')
1029 self
.action_apply_selection
.setText(apply_text
)
1030 self
.action_apply_selection
.setIcon(icons
.add())
1031 add_action(self
.action_apply_selection
)
1033 self
.action_edit_and_apply_selection
.setText(edit_and_apply_text
)
1034 self
.action_edit_and_apply_selection
.setIcon(icons
.add())
1035 add_action(self
.action_edit_and_apply_selection
)
1037 self
.action_revert_selection
.setText(revert_text
)
1038 add_action(self
.action_revert_selection
)
1040 self
.action_edit_and_revert_selection
.setText(edit_and_revert_text
)
1041 add_action(self
.action_edit_and_revert_selection
)
1043 if s
.staged
and model
.is_unstageable():
1045 if item
in model
.submodules
:
1046 path
= core
.abspath(item
)
1047 action
= qtutils
.add_action_with_icon(
1050 cmds
.Unstage
.name(),
1051 cmds
.run(cmds
.Unstage
, context
, s
.staged
),
1052 hotkeys
.STAGE_SELECTION
,
1056 qtutils
.add_action_with_icon(
1059 N_('Launch git-cola'),
1060 cmds
.run(cmds
.OpenRepo
, context
, path
),
1064 elif item
not in model
.staged_deleted
:
1065 if self
.has_selection():
1066 apply_text
= N_('Unstage Selected Lines')
1067 edit_and_apply_text
= N_('Edit Selected Lines to Unstage...')
1069 apply_text
= N_('Unstage Diff Hunk')
1070 edit_and_apply_text
= N_('Edit Diff Hunk to Unstage...')
1072 self
.action_apply_selection
.setText(apply_text
)
1073 self
.action_apply_selection
.setIcon(icons
.remove())
1074 add_action(self
.action_apply_selection
)
1076 self
.action_edit_and_apply_selection
.setText(edit_and_apply_text
)
1077 self
.action_edit_and_apply_selection
.setIcon(icons
.remove())
1078 add_action(self
.action_edit_and_apply_selection
)
1080 if model
.is_stageable() or model
.is_unstageable():
1081 # Do not show the "edit" action when the file does not exist.
1082 # Untracked files exist by definition.
1083 if filename
and core
.exists(filename
):
1084 add_action(qtutils
.menu_separator(menu
))
1085 add_action(self
.launch_editor
)
1087 # Removed files can still be diffed.
1088 add_action(self
.launch_difftool
)
1090 # Add the Previous/Next File actions, which improves discoverability
1091 # of their associated shortcuts
1092 add_action(qtutils
.menu_separator(menu
))
1093 add_action(self
.move_up
)
1094 add_action(self
.move_down
)
1095 add_action(qtutils
.menu_separator(menu
))
1098 first_action
= current_actions
[0]
1101 menu
.insertActions(first_action
, menu_actions
)
1105 def mousePressEvent(self
, event
):
1106 if event
.button() == Qt
.RightButton
:
1107 # Intercept right-click to move the cursor to the current position.
1108 # setTextCursor() clears the selection so this is only done when
1109 # nothing is selected.
1110 if not self
.has_selection():
1111 cursor
= self
.cursorForPosition(event
.pos())
1112 self
.setTextCursor(cursor
)
1114 return super(DiffEditor
, self
).mousePressEvent(event
)
1116 def setPlainText(self
, text
):
1117 """setPlainText(str) while retaining scrollbar positions"""
1120 highlight
= mode
not in (
1123 model
.mode_untracked
,
1125 self
.highlighter
.set_enabled(highlight
)
1127 scrollbar
= self
.verticalScrollBar()
1129 scrollvalue
= get(scrollbar
)
1136 DiffTextEdit
.setPlainText(self
, text
)
1138 if scrollbar
and scrollvalue
is not None:
1139 scrollbar
.setValue(scrollvalue
)
1141 def apply_selection(self
, *, edit
=False):
1143 s
= self
.selection_model
.single_selection()
1144 if model
.is_partially_stageable() and (s
.modified
or s
.untracked
):
1145 self
.process_diff_selection(edit
=edit
)
1146 elif model
.is_unstageable():
1147 self
.process_diff_selection(reverse
=True, edit
=edit
)
1149 def revert_selection(self
, *, edit
=False):
1150 """Destructively revert selected lines or hunk from a worktree file."""
1153 if self
.has_selection():
1154 title
= N_('Revert Selected Lines?')
1155 ok_text
= N_('Revert Selected Lines')
1157 title
= N_('Revert Diff Hunk?')
1158 ok_text
= N_('Revert Diff Hunk')
1160 if not Interaction
.confirm(
1163 'This operation drops uncommitted changes.\n'
1164 'These changes cannot be recovered.'
1166 N_('Revert the uncommitted changes?'),
1172 self
.process_diff_selection(reverse
=True, apply_to_worktree
=True, edit
=edit
)
1174 def extract_patch(self
, reverse
=False):
1175 first_line_idx
, last_line_idx
= self
.selected_lines()
1176 patch
= diffparse
.Patch
.parse(self
.model
.filename
, self
.model
.diff_text
)
1177 if self
.has_selection():
1178 return patch
.extract_subset(first_line_idx
, last_line_idx
, reverse
=reverse
)
1179 return patch
.extract_hunk(first_line_idx
, reverse
=reverse
)
1181 def patch_encoding(self
):
1182 if isinstance(self
.model
.diff_text
, core
.UStr
):
1183 # original encoding must prevail
1184 return self
.model
.diff_text
.encoding
1185 return self
.context
.cfg
.file_encoding(self
.model
.filename
)
1187 def process_diff_selection(
1188 self
, reverse
=False, apply_to_worktree
=False, edit
=False
1190 """Implement un/staging of the selected line(s) or hunk."""
1191 if self
.selection_model
.is_empty():
1193 patch
= self
.extract_patch(reverse
)
1194 if not patch
.has_changes():
1196 patch_encoding
= self
.patch_encoding()
1204 apply_to_worktree
=apply_to_worktree
,
1206 if not patch
.has_changes():
1217 def _update_line_number(self
):
1218 """Update the selection model when the cursor changes"""
1219 self
.selection_model
.line_number
= self
.numbers
.current_line()
1222 class DiffWidget(QtWidgets
.QWidget
):
1223 """Display commit metadata and text diffs"""
1225 def __init__(self
, context
, parent
, is_commit
=False, options
=None):
1226 QtWidgets
.QWidget
.__init
__(self
, parent
)
1228 self
.context
= context
1230 self
.oid_start
= None
1232 self
.options
= options
1234 author_font
= QtGui
.QFont(self
.font())
1235 author_font
.setPointSize(int(author_font
.pointSize() * 1.1))
1237 summary_font
= QtGui
.QFont(author_font
)
1238 summary_font
.setWeight(QtGui
.QFont
.Bold
)
1240 policy
= QtWidgets
.QSizePolicy(
1241 QtWidgets
.QSizePolicy
.MinimumExpanding
, QtWidgets
.QSizePolicy
.Minimum
1244 self
.gravatar_label
= gravatar
.GravatarLabel(self
.context
, parent
=self
)
1246 self
.author_label
= TextLabel()
1247 self
.author_label
.setTextFormat(Qt
.RichText
)
1248 self
.author_label
.setFont(author_font
)
1249 self
.author_label
.setSizePolicy(policy
)
1250 self
.author_label
.setAlignment(Qt
.AlignBottom
)
1251 self
.author_label
.elide()
1253 self
.date_label
= TextLabel()
1254 self
.date_label
.setTextFormat(Qt
.PlainText
)
1255 self
.date_label
.setSizePolicy(policy
)
1256 self
.date_label
.setAlignment(Qt
.AlignTop
)
1257 self
.date_label
.hide()
1259 self
.summary_label
= TextLabel()
1260 self
.summary_label
.setTextFormat(Qt
.PlainText
)
1261 self
.summary_label
.setFont(summary_font
)
1262 self
.summary_label
.setSizePolicy(policy
)
1263 self
.summary_label
.setAlignment(Qt
.AlignTop
)
1264 self
.summary_label
.elide()
1266 self
.oid_label
= TextLabel()
1267 self
.oid_label
.setTextFormat(Qt
.PlainText
)
1268 self
.oid_label
.setSizePolicy(policy
)
1269 self
.oid_label
.setAlignment(Qt
.AlignTop
)
1270 self
.oid_label
.elide()
1272 self
.diff
= DiffTextEdit(context
, self
, is_commit
=is_commit
, whitespace
=False)
1273 self
.setFocusProxy(self
.diff
)
1275 self
.info_layout
= qtutils
.vbox(
1284 self
.logo_layout
= qtutils
.hbox(
1285 defs
.no_margin
, defs
.button_spacing
, self
.gravatar_label
, self
.info_layout
1287 self
.logo_layout
.setContentsMargins(defs
.margin
, 0, defs
.margin
, 0)
1289 self
.main_layout
= qtutils
.vbox(
1290 defs
.no_margin
, defs
.spacing
, self
.logo_layout
, self
.diff
1292 self
.setLayout(self
.main_layout
)
1294 self
.set_tabwidth(prefs
.tabwidth(context
))
1296 def set_tabwidth(self
, width
):
1297 self
.diff
.set_tabwidth(width
)
1299 def set_word_wrapping(self
, enabled
, update
=False):
1300 """Enable and disable word wrapping"""
1301 self
.diff
.set_word_wrapping(enabled
, update
=update
)
1303 def set_options(self
, options
):
1304 """Register an options widget"""
1305 self
.options
= options
1306 self
.diff
.set_options(options
)
1308 def start_diff_task(self
, task
):
1309 """Clear the display and start a diff-gathering task"""
1310 self
.diff
.save_scrollbar()
1311 self
.diff
.set_loading_message()
1312 self
.context
.runtask
.start(task
, result
=self
.set_diff
)
1314 def set_diff_oid(self
, oid
, filename
=None):
1315 """Set the diff from a single commit object ID"""
1316 task
= DiffInfoTask(self
.context
, oid
, filename
)
1317 self
.start_diff_task(task
)
1319 def set_diff_range(self
, start
, end
, filename
=None):
1320 task
= DiffRangeTask(self
.context
, start
+ '~', end
, filename
)
1321 self
.start_diff_task(task
)
1323 def commits_selected(self
, commits
):
1324 """Display an appropriate diff when commits are selected"""
1328 commit
= commits
[-1]
1330 email
= commit
.email
or ''
1331 summary
= commit
.summary
or ''
1332 author
= commit
.author
or ''
1333 self
.set_details(oid
, author
, email
, '', summary
)
1336 if len(commits
) > 1:
1337 start
, end
= commits
[0], commits
[-1]
1338 self
.set_diff_range(start
.oid
, end
.oid
)
1339 self
.oid_start
= start
1342 self
.set_diff_oid(oid
)
1343 self
.oid_start
= None
1346 def set_diff(self
, diff
):
1347 """Set the diff text"""
1348 self
.diff
.set_diff(diff
)
1350 def set_details(self
, oid
, author
, email
, date
, summary
):
1351 template_args
= {'author': author
, 'email': email
, 'summary': summary
}
1353 """%(author)s <"""
1354 """<a href="mailto:%(email)s">"""
1355 """%(email)s</a>>""" % template_args
1357 author_template
= '%(author)s <%(email)s>' % template_args
1359 self
.date_label
.set_text(date
)
1360 self
.date_label
.setVisible(bool(date
))
1361 self
.oid_label
.set_text(oid
)
1362 self
.author_label
.set_template(author_text
, author_template
)
1363 self
.summary_label
.set_text(summary
)
1364 self
.gravatar_label
.set_email(email
)
1367 self
.date_label
.set_text('')
1368 self
.oid_label
.set_text('')
1369 self
.author_label
.set_text('')
1370 self
.summary_label
.set_text('')
1371 self
.gravatar_label
.clear()
1374 def files_selected(self
, filenames
):
1375 """Update the view when a filename is selected"""
1378 oid_start
= self
.oid_start
1379 oid_end
= self
.oid_end
1380 if oid_start
and oid_end
:
1381 self
.set_diff_range(oid_start
.oid
, oid_end
.oid
, filename
=filenames
[0])
1383 self
.set_diff_oid(self
.oid
, filename
=filenames
[0])
1386 class TextLabel(QtWidgets
.QLabel
):
1387 def __init__(self
, parent
=None):
1388 QtWidgets
.QLabel
.__init
__(self
, parent
)
1389 self
.setTextInteractionFlags(
1390 Qt
.TextSelectableByMouse | Qt
.LinksAccessibleByMouse
1396 self
._metrics
= QtGui
.QFontMetrics(self
.font())
1397 self
.setOpenExternalLinks(True)
1402 def set_text(self
, text
):
1403 self
.set_template(text
, text
)
1405 def set_template(self
, text
, template
):
1406 self
._display
= text
1408 self
._template
= template
1409 self
.update_text(self
.width())
1410 self
.setText(self
._display
)
1412 def update_text(self
, width
):
1413 self
._display
= self
._text
1416 text
= self
._metrics
.elidedText(self
._template
, Qt
.ElideRight
, width
- 2)
1417 if text
!= self
._template
:
1418 self
._display
= text
1421 def setFont(self
, font
):
1422 self
._metrics
= QtGui
.QFontMetrics(font
)
1423 QtWidgets
.QLabel
.setFont(self
, font
)
1425 def resizeEvent(self
, event
):
1427 self
.update_text(event
.size().width())
1428 with qtutils
.BlockSignals(self
):
1429 self
.setText(self
._display
)
1430 QtWidgets
.QLabel
.resizeEvent(self
, event
)
1433 class DiffInfoTask(qtutils
.Task
):
1434 """Gather diffs for a single commit"""
1436 def __init__(self
, context
, oid
, filename
):
1437 qtutils
.Task
.__init
__(self
)
1438 self
.context
= context
1440 self
.filename
= filename
1443 context
= self
.context
1445 return gitcmds
.diff_info(context
, oid
, filename
=self
.filename
)
1448 class DiffRangeTask(qtutils
.Task
):
1449 """Gather diffs for a range of commits"""
1451 def __init__(self
, context
, start
, end
, filename
):
1452 qtutils
.Task
.__init
__(self
)
1453 self
.context
= context
1456 self
.filename
= filename
1459 context
= self
.context
1460 return gitcmds
.diff_range(context
, self
.start
, self
.end
, filename
=self
.filename
)