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_triple(cfg
.color('text', '030303'))
67 self
.color_add
= qtutils
.rgb_triple(
68 cfg
.color('add', '77aa77' if dark
else 'd2ffe4')
70 self
.color_remove
= qtutils
.rgb_triple(
71 cfg
.color('remove', 'aa7777' if dark
else 'fee0e4')
73 self
.color_header
= qtutils
.rgb_triple(cfg
.color('header', header
))
75 self
.diff_header_fmt
= qtutils
.make_format(foreground
=self
.color_header
)
76 self
.bold_diff_header_fmt
= qtutils
.make_format(
77 foreground
=self
.color_header
, bold
=True
80 self
.diff_add_fmt
= qtutils
.make_format(
81 foreground
=self
.color_text
, background
=self
.color_add
83 self
.diff_remove_fmt
= qtutils
.make_format(
84 foreground
=self
.color_text
, background
=self
.color_remove
86 self
.bad_whitespace_fmt
= qtutils
.make_format(background
=Qt
.red
)
87 self
.setCurrentBlockState(self
.INITIAL_STATE
)
89 def set_enabled(self
, enabled
):
90 self
.enabled
= enabled
92 def highlightBlock(self
, text
):
93 """Highlight the current text block"""
94 if not self
.enabled
or not text
:
97 state
= self
.get_next_state(text
)
98 if state
== self
.DIFFSTAT_STATE
:
99 state
, formats
= self
.get_formats_for_diffstat(state
, text
)
100 elif state
== self
.DIFF_FILE_HEADER_STATE
:
101 state
, formats
= self
.get_formats_for_diff_header(state
, text
)
102 elif state
== self
.DIFF_STATE
:
103 state
, formats
= self
.get_formats_for_diff_text(state
, text
)
105 for start
, end
, fmt
in formats
:
106 self
.setFormat(start
, end
, fmt
)
108 self
.setCurrentBlockState(state
)
110 def get_next_state(self
, text
):
111 """Transition to the next state based on the input text"""
112 state
= self
.previousBlockState()
113 if state
== DiffSyntaxHighlighter
.INITIAL_STATE
:
114 if text
.startswith('Submodule '):
115 state
= DiffSyntaxHighlighter
.SUBMODULE_STATE
116 elif text
.startswith('diff --git '):
117 state
= DiffSyntaxHighlighter
.DIFFSTAT_STATE
119 state
= DiffSyntaxHighlighter
.DEFAULT_STATE
121 state
= DiffSyntaxHighlighter
.DIFFSTAT_STATE
125 def get_formats_for_diffstat(self
, state
, text
):
126 """Returns (state, [(start, end, fmt), ...]) for highlighting diffstat text"""
128 if self
.DIFF_FILE_HEADER_START_RGX
.match(text
):
129 state
= self
.DIFF_FILE_HEADER_STATE
131 fmt
= self
.diff_header_fmt
132 formats
.append((0, end
, fmt
))
133 elif self
.DIFF_HUNK_HEADER_RGX
.match(text
):
134 state
= self
.DIFF_STATE
136 fmt
= self
.bold_diff_header_fmt
137 formats
.append((0, end
, fmt
))
139 offset
= text
.index('|')
140 formats
.append((0, offset
, self
.bold_diff_header_fmt
))
141 formats
.append((offset
, len(text
) - offset
, self
.diff_header_fmt
))
143 formats
.append((0, len(text
), self
.diff_header_fmt
))
145 return state
, formats
147 def get_formats_for_diff_header(self
, state
, text
):
148 """Returns (state, [(start, end, fmt), ...]) for highlighting diff headers"""
150 if self
.DIFF_HUNK_HEADER_RGX
.match(text
):
151 state
= self
.DIFF_STATE
152 formats
.append((0, len(text
), self
.bold_diff_header_fmt
))
154 formats
.append((0, len(text
), self
.diff_header_fmt
))
156 return state
, formats
158 def get_formats_for_diff_text(self
, state
, text
):
159 """Return (state, [(start, end fmt), ...]) for highlighting diff text"""
162 if self
.DIFF_FILE_HEADER_START_RGX
.match(text
):
163 state
= self
.DIFF_FILE_HEADER_STATE
164 formats
.append((0, len(text
), self
.diff_header_fmt
))
166 elif self
.DIFF_HUNK_HEADER_RGX
.match(text
):
167 formats
.append((0, len(text
), self
.bold_diff_header_fmt
))
169 elif text
.startswith('-'):
171 state
= self
.END_STATE
173 formats
.append((0, len(text
), self
.diff_remove_fmt
))
175 elif text
.startswith('+'):
176 formats
.append((0, len(text
), self
.diff_add_fmt
))
178 match
= self
.BAD_WHITESPACE_RGX
.search(text
)
179 if match
is not None:
180 start
= match
.start()
181 formats
.append((start
, len(text
) - start
, self
.bad_whitespace_fmt
))
183 return state
, formats
186 # pylint: disable=too-many-ancestors
187 class DiffTextEdit(VimHintedPlainTextEdit
):
188 """A textedit for interacting with diff text"""
191 self
, context
, parent
, is_commit
=False, whitespace
=True, numbers
=False
193 VimHintedPlainTextEdit
.__init
__(self
, context
, '', parent
=parent
)
194 # Diff/patch syntax highlighter
195 self
.highlighter
= DiffSyntaxHighlighter(
196 context
, self
.document(), is_commit
=is_commit
, whitespace
=whitespace
199 self
.numbers
= DiffLineNumbers(context
, self
)
203 self
.scrollvalue
= None
205 self
.copy_diff_action
= qtutils
.add_action(
211 self
.copy_diff_action
.setIcon(icons
.copy())
212 self
.copy_diff_action
.setEnabled(False)
213 self
.menu_actions
.append(self
.copy_diff_action
)
215 # pylint: disable=no-member
216 self
.cursorPositionChanged
.connect(self
._cursor
_changed
, Qt
.QueuedConnection
)
217 self
.selectionChanged
.connect(self
._selection
_changed
, Qt
.QueuedConnection
)
219 def setFont(self
, font
):
220 """Override setFont() so that we can use a custom "block" cursor"""
221 super(DiffTextEdit
, self
).setFont(font
)
222 if prefs
.block_cursor(self
.context
):
223 metrics
= QtGui
.QFontMetrics(font
)
224 width
= metrics
.width('M')
225 self
.setCursorWidth(width
)
227 def _cursor_changed(self
):
228 """Update the line number display when the cursor changes"""
229 line_number
= max(0, self
.textCursor().blockNumber())
231 self
.numbers
.set_highlighted(line_number
)
233 def _selection_changed(self
):
234 """Respond to selection changes"""
235 selected
= bool(self
.selected_text())
236 self
.copy_diff_action
.setEnabled(selected
)
238 def resizeEvent(self
, event
):
239 super(DiffTextEdit
, self
).resizeEvent(event
)
241 self
.numbers
.refresh_size()
243 def save_scrollbar(self
):
244 """Save the scrollbar value, but only on the first call"""
245 if self
.scrollvalue
is None:
246 scrollbar
= self
.verticalScrollBar()
248 scrollvalue
= get(scrollbar
)
251 self
.scrollvalue
= scrollvalue
253 def restore_scrollbar(self
):
254 """Restore the scrollbar and clear state"""
255 scrollbar
= self
.verticalScrollBar()
256 scrollvalue
= self
.scrollvalue
257 if scrollbar
and scrollvalue
is not None:
258 scrollbar
.setValue(scrollvalue
)
259 self
.scrollvalue
= None
261 def set_loading_message(self
):
262 """Add a pending loading message in the diff view"""
263 self
.hint
.set_value('+++ ' + N_('Loading...'))
266 def set_diff(self
, diff
):
267 """Set the diff text, but save the scrollbar"""
268 diff
= diff
.rstrip('\n') # diffs include two empty newlines
269 self
.save_scrollbar()
271 self
.hint
.set_value('')
273 self
.numbers
.set_diff(diff
)
276 self
.restore_scrollbar()
278 def selected_diff_stripped(self
):
279 """Return the selected diff stripped of any diff characters"""
280 sep
, selection
= self
.selected_text_lines()
281 return sep
.join(_strip_diff(line
) for line
in selection
)
284 """Copy the selected diff text stripped of any diff prefix characters"""
285 text
= self
.selected_diff_stripped()
286 qtutils
.set_clipboard(text
)
288 def selected_lines(self
):
289 """Return selected lines"""
290 cursor
= self
.textCursor()
291 selection_start
= cursor
.selectionStart()
292 selection_end
= max(selection_start
, cursor
.selectionEnd() - 1)
299 for line_idx
, line
in enumerate(get(self
, default
='').splitlines()):
300 line_end
= line_start
+ len(line
)
301 if line_start
<= selection_start
<= line_end
:
302 first_line_idx
= line_idx
303 if line_start
<= selection_end
<= line_end
:
304 last_line_idx
= line_idx
306 line_start
= line_end
+ 1
308 if first_line_idx
== -1:
309 first_line_idx
= line_idx
311 if last_line_idx
== -1:
312 last_line_idx
= line_idx
314 return first_line_idx
, last_line_idx
316 def selected_text_lines(self
):
317 """Return selected lines and the CRLF / LF separator"""
318 first_line_idx
, last_line_idx
= self
.selected_lines()
319 text
= get(self
, default
='')
322 for line_idx
, line
in enumerate(text
.split(sep
)):
323 if first_line_idx
<= line_idx
<= last_line_idx
:
329 """Return either CRLF or LF based on the content"""
337 def _strip_diff(value
):
338 """Remove +/-/<space> from a selection"""
339 if value
.startswith(('+', '-', ' ')):
344 class DiffLineNumbers(TextDecorator
):
345 def __init__(self
, context
, parent
):
346 TextDecorator
.__init
__(self
, parent
)
347 self
.highlight_line
= -1
349 self
.parser
= diffparse
.DiffLines()
350 self
.formatter
= diffparse
.FormatDigits()
352 self
.setFont(qtutils
.diff_font(context
))
353 self
._char
_width
= self
.fontMetrics().width('0')
355 QPalette
= QtGui
.QPalette
356 self
._palette
= palette
= self
.palette()
357 self
._base
= palette
.color(QtGui
.QPalette
.Base
)
358 self
._highlight
= palette
.color(QPalette
.Highlight
)
359 self
._highlight
.setAlphaF(0.3)
360 self
._highlight
_text
= palette
.color(QPalette
.HighlightedText
)
361 self
._window
= palette
.color(QPalette
.Window
)
362 self
._disabled
= palette
.color(QPalette
.Disabled
, QPalette
.Text
)
364 def set_diff(self
, diff
):
365 self
.lines
= self
.parser
.parse(diff
)
366 self
.formatter
.set_digits(self
.parser
.digits())
368 def width_hint(self
):
369 if not self
.isVisible():
375 extra
= 3 # one space in-between, one space after
378 extra
= 2 # one space in-between, one space after
380 digits
= parser
.digits() * columns
382 return defs
.margin
+ (self
._char
_width
* (digits
+ extra
))
384 def set_highlighted(self
, line_number
):
385 """Set the line to highlight"""
386 self
.highlight_line
= line_number
388 def current_line(self
):
390 if lines
and self
.highlight_line
>= 0:
391 # Find the next valid line
392 for i
in range(self
.highlight_line
, len(lines
)):
393 # take the "new" line number: last value in tuple
394 line_number
= lines
[i
][-1]
398 # Find the previous valid line
399 for i
in range(self
.highlight_line
- 1, -1, -1):
400 # take the "new" line number: last value in tuple
402 line_number
= lines
[i
][-1]
407 def paintEvent(self
, event
):
408 """Paint the line number"""
412 painter
= QtGui
.QPainter(self
)
413 painter
.fillRect(event
.rect(), self
._base
)
416 content_offset
= editor
.contentOffset()
417 block
= editor
.firstVisibleBlock()
419 text_width
= width
- (defs
.margin
* 2)
420 text_flags
= Qt
.AlignRight | Qt
.AlignVCenter
421 event_rect_bottom
= event
.rect().bottom()
423 highlight_line
= self
.highlight_line
424 highlight
= self
._highlight
425 highlight_text
= self
._highlight
_text
426 disabled
= self
._disabled
430 num_lines
= len(lines
)
432 while block
.isValid():
433 block_number
= block
.blockNumber()
434 if block_number
>= num_lines
:
436 block_geom
= editor
.blockBoundingGeometry(block
)
437 rect
= block_geom
.translated(content_offset
).toRect()
438 if not block
.isVisible() or rect
.top() >= event_rect_bottom
:
441 if block_number
== highlight_line
:
442 painter
.fillRect(rect
.x(), rect
.y(), width
, rect
.height(), highlight
)
443 painter
.setPen(highlight_text
)
445 painter
.setPen(disabled
)
447 line
= lines
[block_number
]
450 text
= fmt
.value(a
, b
)
452 old
, base
, new
= line
453 text
= fmt
.merge_value(old
, base
, new
)
467 class Viewer(QtWidgets
.QFrame
):
468 """Text and image diff viewers"""
473 def __init__(self
, context
, parent
=None):
474 super(Viewer
, self
).__init
__(parent
)
476 self
.context
= context
477 self
.model
= model
= context
.model
480 self
.options
= options
= Options(self
)
481 self
.text
= DiffEditor(context
, options
, self
)
482 self
.image
= imageview
.ImageView(parent
=self
)
483 self
.image
.setFocusPolicy(Qt
.NoFocus
)
484 self
.search_widget
= TextSearchWidget(self
)
485 self
.search_widget
.hide()
487 stack
= self
.stack
= QtWidgets
.QStackedWidget(self
)
488 stack
.addWidget(self
.text
)
489 stack
.addWidget(self
.image
)
491 self
.main_layout
= qtutils
.vbox(
497 self
.setLayout(self
.main_layout
)
500 model
.images_changed
.connect(self
.set_images
, type=Qt
.QueuedConnection
)
502 # Observe the diff type
503 model
.diff_type_changed
.connect(self
.set_diff_type
, type=Qt
.QueuedConnection
)
505 # Observe the file type
506 model
.file_type_changed
.connect(self
.set_file_type
, type=Qt
.QueuedConnection
)
508 # Observe the image mode combo box
509 options
.image_mode
.currentIndexChanged
.connect(lambda _
: self
.render())
510 options
.zoom_mode
.currentIndexChanged
.connect(lambda _
: self
.render())
512 self
.setFocusProxy(self
.text
)
514 self
.search_widget
.search_text
.connect(self
.search_text
)
516 self
.search_action
= qtutils
.add_action(
518 N_('Search in Diff'),
519 self
.show_search_diff
,
522 self
.search_next_action
= qtutils
.add_action(
524 N_('Find next item'),
525 self
.search_widget
.search
,
528 self
.search_prev_action
= qtutils
.add_action(
530 N_('Find previous item'),
531 self
.search_widget
.search_backwards
,
535 def show_search_diff(self
):
536 """Show a dialog for searching diffs"""
537 # The diff search is only active in text mode.
538 if self
.stack
.currentIndex() != self
.INDEX_TEXT
:
540 if not self
.search_widget
.isVisible():
541 self
.search_widget
.show()
542 self
.search_widget
.setFocus(True)
544 def search_text(self
, text
, backwards
):
545 """Search the diff text for the given text"""
546 cursor
= self
.text
.textCursor()
547 if cursor
.hasSelection():
548 selected_text
= cursor
.selectedText()
549 case_sensitive
= self
.search_widget
.is_case_sensitive()
550 if text_matches(case_sensitive
, selected_text
, text
):
552 position
= cursor
.selectionStart()
554 position
= cursor
.selectionEnd()
557 position
= cursor
.selectionEnd()
559 position
= cursor
.selectionStart()
560 cursor
.setPosition(position
)
561 self
.text
.setTextCursor(cursor
)
563 flags
= self
.search_widget
.find_flags(backwards
)
564 if not self
.text
.find(text
, flags
):
566 location
= QtGui
.QTextCursor
.End
568 location
= QtGui
.QTextCursor
.Start
569 cursor
.movePosition(location
, QtGui
.QTextCursor
.MoveAnchor
)
570 self
.text
.setTextCursor(cursor
)
571 self
.text
.find(text
, flags
)
573 def export_state(self
, state
):
574 state
['show_diff_line_numbers'] = self
.options
.show_line_numbers
.isChecked()
575 state
['image_diff_mode'] = self
.options
.image_mode
.currentIndex()
576 state
['image_zoom_mode'] = self
.options
.zoom_mode
.currentIndex()
577 state
['word_wrap'] = self
.options
.enable_word_wrapping
.isChecked()
580 def apply_state(self
, state
):
581 diff_numbers
= bool(state
.get('show_diff_line_numbers', False))
582 self
.set_line_numbers(diff_numbers
, update
=True)
584 image_mode
= utils
.asint(state
.get('image_diff_mode', 0))
585 self
.options
.image_mode
.set_index(image_mode
)
587 zoom_mode
= utils
.asint(state
.get('image_zoom_mode', 0))
588 self
.options
.zoom_mode
.set_index(zoom_mode
)
590 word_wrap
= bool(state
.get('word_wrap', True))
591 self
.set_word_wrapping(word_wrap
, update
=True)
594 def set_diff_type(self
, diff_type
):
595 """Manage the image and text diff views when selection changes"""
596 # The "diff type" is whether the diff viewer is displaying an image.
597 self
.options
.set_diff_type(diff_type
)
598 if diff_type
== main
.Types
.IMAGE
:
599 self
.stack
.setCurrentWidget(self
.image
)
600 self
.search_widget
.hide()
603 self
.stack
.setCurrentWidget(self
.text
)
605 def set_file_type(self
, file_type
):
606 """Manage the diff options when the file type changes"""
607 # The "file type" is whether the file itself is an image.
608 self
.options
.set_file_type(file_type
)
610 def update_options(self
):
611 """Emit a signal indicating that options have changed"""
612 self
.text
.update_options()
614 def set_line_numbers(self
, enabled
, update
=False):
615 """Enable/disable line numbers in the text widget"""
616 self
.text
.set_line_numbers(enabled
, update
=update
)
618 def set_word_wrapping(self
, enabled
, update
=False):
619 """Enable/disable word wrapping in the text widget"""
620 self
.text
.set_word_wrapping(enabled
, update
=update
)
623 self
.image
.pixmap
= QtGui
.QPixmap()
627 for (image
, unlink
) in self
.images
:
628 if unlink
and core
.exists(image
):
632 def set_images(self
, images
):
639 # In order to comp, we first have to load all the images
640 all_pixmaps
= [QtGui
.QPixmap(image
[0]) for image
in images
]
641 pixmaps
= [pixmap
for pixmap
in all_pixmaps
if not pixmap
.isNull()]
646 self
.pixmaps
= pixmaps
654 mode
= self
.options
.image_mode
.currentIndex()
655 if mode
== self
.options
.SIDE_BY_SIDE
:
656 image
= self
.render_side_by_side()
657 elif mode
== self
.options
.DIFF
:
658 image
= self
.render_diff()
659 elif mode
== self
.options
.XOR
:
660 image
= self
.render_xor()
661 elif mode
== self
.options
.PIXEL_XOR
:
662 image
= self
.render_pixel_xor()
664 image
= self
.render_side_by_side()
666 image
= QtGui
.QPixmap()
667 self
.image
.pixmap
= image
670 zoom_mode
= self
.options
.zoom_mode
.currentIndex()
671 zoom_factor
= self
.options
.zoom_factors
[zoom_mode
][1]
672 if zoom_factor
> 0.0:
673 self
.image
.resetTransform()
674 self
.image
.scale(zoom_factor
, zoom_factor
)
675 poly
= self
.image
.mapToScene(self
.image
.viewport().rect())
676 self
.image
.last_scene_roi
= poly
.boundingRect()
678 def render_side_by_side(self
):
679 # Side-by-side lineup comp
680 pixmaps
= self
.pixmaps
681 width
= sum(pixmap
.width() for pixmap
in pixmaps
)
682 height
= max(pixmap
.height() for pixmap
in pixmaps
)
683 image
= create_image(width
, height
)
686 painter
= create_painter(image
)
688 for pixmap
in pixmaps
:
689 painter
.drawPixmap(x
, 0, pixmap
)
695 def render_comp(self
, comp_mode
):
696 # Get the max size to use as the render canvas
697 pixmaps
= self
.pixmaps
698 if len(pixmaps
) == 1:
701 width
= max(pixmap
.width() for pixmap
in pixmaps
)
702 height
= max(pixmap
.height() for pixmap
in pixmaps
)
703 image
= create_image(width
, height
)
705 painter
= create_painter(image
)
706 for pixmap
in (pixmaps
[0], pixmaps
[-1]):
707 x
= (width
- pixmap
.width()) // 2
708 y
= (height
- pixmap
.height()) // 2
709 painter
.drawPixmap(x
, y
, pixmap
)
710 painter
.setCompositionMode(comp_mode
)
715 def render_diff(self
):
716 comp_mode
= QtGui
.QPainter
.CompositionMode_Difference
717 return self
.render_comp(comp_mode
)
719 def render_xor(self
):
720 comp_mode
= QtGui
.QPainter
.CompositionMode_Xor
721 return self
.render_comp(comp_mode
)
723 def render_pixel_xor(self
):
724 comp_mode
= QtGui
.QPainter
.RasterOp_SourceXorDestination
725 return self
.render_comp(comp_mode
)
728 def create_image(width
, height
):
729 size
= QtCore
.QSize(width
, height
)
730 image
= QtGui
.QImage(size
, QtGui
.QImage
.Format_ARGB32_Premultiplied
)
731 image
.fill(Qt
.transparent
)
735 def text_matches(case_sensitive
, a
, b
):
736 """Compare text with case sensitivity taken into account"""
739 return a
.lower() == b
.lower()
742 def create_painter(image
):
743 painter
= QtGui
.QPainter(image
)
744 painter
.fillRect(image
.rect(), Qt
.transparent
)
748 class Options(QtWidgets
.QWidget
):
749 """Provide the options widget used by the editor
751 Actions are registered on the parent widget.
755 # mode combobox indexes
761 def __init__(self
, parent
):
762 super(Options
, self
).__init
__(parent
)
765 self
.ignore_space_at_eol
= self
.add_option(
766 N_('Ignore changes in whitespace at EOL')
769 self
.ignore_space_change
= self
.add_option(
770 N_('Ignore changes in amount of whitespace')
773 self
.ignore_all_space
= self
.add_option(N_('Ignore all whitespace'))
775 self
.function_context
= self
.add_option(
776 N_('Show whole surrounding functions of changes')
779 self
.show_line_numbers
= qtutils
.add_action_bool(
780 self
, N_('Show line numbers'), self
.set_line_numbers
, True
782 self
.enable_word_wrapping
= qtutils
.add_action_bool(
783 self
, N_('Enable word wrapping'), self
.set_word_wrapping
, True
786 self
.options
= qtutils
.create_action_button(
787 tooltip
=N_('Diff Options'), icon
=icons
.configure()
790 self
.toggle_image_diff
= qtutils
.create_action_button(
791 tooltip
=N_('Toggle image diff'), icon
=icons
.visualize()
793 self
.toggle_image_diff
.hide()
795 self
.image_mode
= qtutils
.combo(
796 [N_('Side by side'), N_('Diff'), N_('XOR'), N_('Pixel XOR')]
799 self
.zoom_factors
= (
800 (N_('Zoom to Fit'), 0.0),
808 zoom_modes
= [factor
[0] for factor
in self
.zoom_factors
]
809 self
.zoom_mode
= qtutils
.combo(zoom_modes
, parent
=self
)
811 self
.menu
= menu
= qtutils
.create_menu(N_('Diff Options'), self
.options
)
812 self
.options
.setMenu(menu
)
813 menu
.addAction(self
.ignore_space_at_eol
)
814 menu
.addAction(self
.ignore_space_change
)
815 menu
.addAction(self
.ignore_all_space
)
817 menu
.addAction(self
.function_context
)
818 menu
.addAction(self
.show_line_numbers
)
820 menu
.addAction(self
.enable_word_wrapping
)
823 layout
= qtutils
.hbox(
829 self
.toggle_image_diff
,
831 self
.setLayout(layout
)
834 self
.image_mode
.setFocusPolicy(Qt
.NoFocus
)
835 self
.zoom_mode
.setFocusPolicy(Qt
.NoFocus
)
836 self
.options
.setFocusPolicy(Qt
.NoFocus
)
837 self
.toggle_image_diff
.setFocusPolicy(Qt
.NoFocus
)
838 self
.setFocusPolicy(Qt
.NoFocus
)
840 def set_file_type(self
, file_type
):
841 """Set whether we are viewing an image file type"""
842 is_image
= file_type
== main
.Types
.IMAGE
843 self
.toggle_image_diff
.setVisible(is_image
)
845 def set_diff_type(self
, diff_type
):
846 """Toggle between image and text diffs"""
847 is_text
= diff_type
== main
.Types
.TEXT
848 is_image
= diff_type
== main
.Types
.IMAGE
849 self
.options
.setVisible(is_text
)
850 self
.image_mode
.setVisible(is_image
)
851 self
.zoom_mode
.setVisible(is_image
)
853 self
.toggle_image_diff
.setIcon(icons
.diff())
855 self
.toggle_image_diff
.setIcon(icons
.visualize())
857 def add_option(self
, title
):
858 """Add a diff option which calls update_options() on change"""
859 action
= qtutils
.add_action(self
, title
, self
.update_options
)
860 action
.setCheckable(True)
863 def update_options(self
):
864 """Update diff options in response to UI events"""
865 space_at_eol
= get(self
.ignore_space_at_eol
)
866 space_change
= get(self
.ignore_space_change
)
867 all_space
= get(self
.ignore_all_space
)
868 function_context
= get(self
.function_context
)
869 gitcmds
.update_diff_overrides(
870 space_at_eol
, space_change
, all_space
, function_context
872 self
.widget
.update_options()
874 def set_line_numbers(self
, value
):
875 """Enable / disable line numbers"""
876 self
.widget
.set_line_numbers(value
, update
=False)
878 def set_word_wrapping(self
, value
):
879 """Respond to Qt action callbacks"""
880 self
.widget
.set_word_wrapping(value
, update
=False)
882 def hide_advanced_options(self
):
883 """Hide advanced options that are not applicable to the DiffWidget"""
884 self
.show_line_numbers
.setVisible(False)
885 self
.ignore_space_at_eol
.setVisible(False)
886 self
.ignore_space_change
.setVisible(False)
887 self
.ignore_all_space
.setVisible(False)
888 self
.function_context
.setVisible(False)
891 # pylint: disable=too-many-ancestors
892 class DiffEditor(DiffTextEdit
):
896 options_changed
= Signal()
898 def __init__(self
, context
, options
, parent
):
899 DiffTextEdit
.__init
__(self
, context
, parent
, numbers
=True)
900 self
.context
= context
901 self
.model
= model
= context
.model
902 self
.selection_model
= selection_model
= context
.selection
904 # "Diff Options" tool menu
905 self
.options
= options
907 self
.action_apply_selection
= qtutils
.add_action(
910 self
.apply_selection
,
912 hotkeys
.STAGE_DIFF_ALT
,
915 self
.action_revert_selection
= qtutils
.add_action(
916 self
, 'Revert', self
.revert_selection
, hotkeys
.REVERT
918 self
.action_revert_selection
.setIcon(icons
.undo())
920 self
.action_edit_and_apply_selection
= qtutils
.add_action(
923 partial(self
.apply_selection
, edit
=True),
924 hotkeys
.EDIT_AND_STAGE_DIFF
,
927 self
.action_edit_and_revert_selection
= qtutils
.add_action(
930 partial(self
.revert_selection
, edit
=True),
931 hotkeys
.EDIT_AND_REVERT
,
933 self
.action_edit_and_revert_selection
.setIcon(icons
.undo())
934 self
.launch_editor
= actions
.launch_editor_at_line(
935 context
, self
, hotkeys
.EDIT_SHORT
, *hotkeys
.ACCEPT
937 self
.launch_difftool
= actions
.launch_difftool(context
, self
)
938 self
.stage_or_unstage
= actions
.stage_or_unstage(context
, self
)
940 # Emit up/down signals so that they can be routed by the main widget
941 self
.move_up
= actions
.move_up(self
)
942 self
.move_down
= actions
.move_down(self
)
944 model
.diff_text_updated
.connect(self
.set_diff
, type=Qt
.QueuedConnection
)
946 selection_model
.selection_changed
.connect(
947 self
.refresh
, type=Qt
.QueuedConnection
949 # Update the selection model when the cursor changes
950 self
.cursorPositionChanged
.connect(self
._update
_line
_number
)
952 qtutils
.connect_button(options
.toggle_image_diff
, self
.toggle_diff_type
)
954 def toggle_diff_type(self
):
955 cmds
.do(cmds
.ToggleDiffType
, self
.context
)
959 s
= self
.selection_model
.selection()
961 if model
.is_partially_stageable():
962 item
= s
.modified
[0] if s
.modified
else None
963 if item
in model
.submodules
:
965 elif item
not in model
.unstaged_deleted
:
967 self
.action_revert_selection
.setEnabled(enabled
)
969 def set_line_numbers(self
, enabled
, update
=False):
970 """Enable/disable the diff line number display"""
971 self
.numbers
.setVisible(enabled
)
973 with qtutils
.BlockSignals(self
.options
.show_line_numbers
):
974 self
.options
.show_line_numbers
.setChecked(enabled
)
975 # Refresh the display. Not doing this results in the display not
976 # correctly displaying the line numbers widget until the text scrolls.
977 self
.set_value(self
.value())
979 def update_options(self
):
980 self
.options_changed
.emit()
982 def create_context_menu(self
, event_pos
):
983 """Override create_context_menu() to display a completely custom menu"""
984 menu
= super(DiffEditor
, self
).create_context_menu(event_pos
)
985 context
= self
.context
987 s
= self
.selection_model
.selection()
988 filename
= self
.selection_model
.filename()
990 # These menu actions will be inserted at the start of the widget.
991 current_actions
= menu
.actions()
993 add_action
= menu_actions
.append
995 if model
.is_stageable() or model
.is_unstageable():
996 if model
.is_stageable():
997 self
.stage_or_unstage
.setText(N_('Stage'))
998 self
.stage_or_unstage
.setIcon(icons
.add())
1000 self
.stage_or_unstage
.setText(N_('Unstage'))
1001 self
.stage_or_unstage
.setIcon(icons
.remove())
1002 add_action(self
.stage_or_unstage
)
1004 if model
.is_partially_stageable():
1005 item
= s
.modified
[0] if s
.modified
else None
1006 if item
in model
.submodules
:
1007 path
= core
.abspath(item
)
1008 action
= qtutils
.add_action_with_icon(
1012 cmds
.run(cmds
.Stage
, context
, s
.modified
),
1013 hotkeys
.STAGE_SELECTION
,
1017 action
= qtutils
.add_action_with_icon(
1020 N_('Launch git-cola'),
1021 cmds
.run(cmds
.OpenRepo
, context
, path
),
1024 elif item
not in model
.unstaged_deleted
:
1025 if self
.has_selection():
1026 apply_text
= N_('Stage Selected Lines')
1027 edit_and_apply_text
= N_('Edit Selected Lines to Stage...')
1028 revert_text
= N_('Revert Selected Lines...')
1029 edit_and_revert_text
= N_('Edit Selected Lines to Revert...')
1031 apply_text
= N_('Stage Diff Hunk')
1032 edit_and_apply_text
= N_('Edit Diff Hunk to Stage...')
1033 revert_text
= N_('Revert Diff Hunk...')
1034 edit_and_revert_text
= N_('Edit Diff Hunk to Revert...')
1036 self
.action_apply_selection
.setText(apply_text
)
1037 self
.action_apply_selection
.setIcon(icons
.add())
1038 add_action(self
.action_apply_selection
)
1040 self
.action_edit_and_apply_selection
.setText(edit_and_apply_text
)
1041 self
.action_edit_and_apply_selection
.setIcon(icons
.add())
1042 add_action(self
.action_edit_and_apply_selection
)
1044 self
.action_revert_selection
.setText(revert_text
)
1045 add_action(self
.action_revert_selection
)
1047 self
.action_edit_and_revert_selection
.setText(edit_and_revert_text
)
1048 add_action(self
.action_edit_and_revert_selection
)
1050 if s
.staged
and model
.is_unstageable():
1052 if item
in model
.submodules
:
1053 path
= core
.abspath(item
)
1054 action
= qtutils
.add_action_with_icon(
1057 cmds
.Unstage
.name(),
1058 cmds
.run(cmds
.Unstage
, context
, s
.staged
),
1059 hotkeys
.STAGE_SELECTION
,
1063 qtutils
.add_action_with_icon(
1066 N_('Launch git-cola'),
1067 cmds
.run(cmds
.OpenRepo
, context
, path
),
1071 elif item
not in model
.staged_deleted
:
1072 if self
.has_selection():
1073 apply_text
= N_('Unstage Selected Lines')
1074 edit_and_apply_text
= N_('Edit Selected Lines to Unstage...')
1076 apply_text
= N_('Unstage Diff Hunk')
1077 edit_and_apply_text
= N_('Edit Diff Hunk to Unstage...')
1079 self
.action_apply_selection
.setText(apply_text
)
1080 self
.action_apply_selection
.setIcon(icons
.remove())
1081 add_action(self
.action_apply_selection
)
1083 self
.action_edit_and_apply_selection
.setText(edit_and_apply_text
)
1084 self
.action_edit_and_apply_selection
.setIcon(icons
.remove())
1085 add_action(self
.action_edit_and_apply_selection
)
1087 if model
.is_stageable() or model
.is_unstageable():
1088 # Do not show the "edit" action when the file does not exist.
1089 # Untracked files exist by definition.
1090 if filename
and core
.exists(filename
):
1091 add_action(qtutils
.menu_separator(menu
))
1092 add_action(self
.launch_editor
)
1094 # Removed files can still be diffed.
1095 add_action(self
.launch_difftool
)
1097 # Add the Previous/Next File actions, which improves discoverability
1098 # of their associated shortcuts
1099 add_action(qtutils
.menu_separator(menu
))
1100 add_action(self
.move_up
)
1101 add_action(self
.move_down
)
1102 add_action(qtutils
.menu_separator(menu
))
1105 first_action
= current_actions
[0]
1108 menu
.insertActions(first_action
, menu_actions
)
1112 def mousePressEvent(self
, event
):
1113 if event
.button() == Qt
.RightButton
:
1114 # Intercept right-click to move the cursor to the current position.
1115 # setTextCursor() clears the selection so this is only done when
1116 # nothing is selected.
1117 if not self
.has_selection():
1118 cursor
= self
.cursorForPosition(event
.pos())
1119 self
.setTextCursor(cursor
)
1121 return super(DiffEditor
, self
).mousePressEvent(event
)
1123 def setPlainText(self
, text
):
1124 """setPlainText(str) while retaining scrollbar positions"""
1127 highlight
= mode
not in (
1130 model
.mode_untracked
,
1132 self
.highlighter
.set_enabled(highlight
)
1134 scrollbar
= self
.verticalScrollBar()
1136 scrollvalue
= get(scrollbar
)
1143 DiffTextEdit
.setPlainText(self
, text
)
1145 if scrollbar
and scrollvalue
is not None:
1146 scrollbar
.setValue(scrollvalue
)
1148 def apply_selection(self
, *, edit
=False):
1150 s
= self
.selection_model
.single_selection()
1151 if model
.is_partially_stageable() and (s
.modified
or s
.untracked
):
1152 self
.process_diff_selection(edit
=edit
)
1153 elif model
.is_unstageable():
1154 self
.process_diff_selection(reverse
=True, edit
=edit
)
1156 def revert_selection(self
, *, edit
=False):
1157 """Destructively revert selected lines or hunk from a worktree file."""
1160 if self
.has_selection():
1161 title
= N_('Revert Selected Lines?')
1162 ok_text
= N_('Revert Selected Lines')
1164 title
= N_('Revert Diff Hunk?')
1165 ok_text
= N_('Revert Diff Hunk')
1167 if not Interaction
.confirm(
1170 'This operation drops uncommitted changes.\n'
1171 'These changes cannot be recovered.'
1173 N_('Revert the uncommitted changes?'),
1179 self
.process_diff_selection(reverse
=True, apply_to_worktree
=True, edit
=edit
)
1181 def extract_patch(self
, reverse
=False):
1182 first_line_idx
, last_line_idx
= self
.selected_lines()
1183 patch
= diffparse
.Patch
.parse(self
.model
.filename
, self
.model
.diff_text
)
1184 if self
.has_selection():
1185 return patch
.extract_subset(first_line_idx
, last_line_idx
, reverse
=reverse
)
1186 return patch
.extract_hunk(first_line_idx
, reverse
=reverse
)
1188 def patch_encoding(self
):
1189 if isinstance(self
.model
.diff_text
, core
.UStr
):
1190 # original encoding must prevail
1191 return self
.model
.diff_text
.encoding
1192 return self
.context
.cfg
.file_encoding(self
.model
.filename
)
1194 def process_diff_selection(
1195 self
, reverse
=False, apply_to_worktree
=False, edit
=False
1197 """Implement un/staging of the selected line(s) or hunk."""
1198 if self
.selection_model
.is_empty():
1200 patch
= self
.extract_patch(reverse
)
1201 if not patch
.has_changes():
1203 patch_encoding
= self
.patch_encoding()
1211 apply_to_worktree
=apply_to_worktree
,
1213 if not patch
.has_changes():
1224 def _update_line_number(self
):
1225 """Update the selection model when the cursor changes"""
1226 self
.selection_model
.line_number
= self
.numbers
.current_line()
1229 class DiffWidget(QtWidgets
.QWidget
):
1230 """Display commit metadata and text diffs"""
1232 def __init__(self
, context
, parent
, is_commit
=False, options
=None):
1233 QtWidgets
.QWidget
.__init
__(self
, parent
)
1235 self
.context
= context
1237 self
.oid_start
= None
1239 self
.options
= options
1241 author_font
= QtGui
.QFont(self
.font())
1242 author_font
.setPointSize(int(author_font
.pointSize() * 1.1))
1244 summary_font
= QtGui
.QFont(author_font
)
1245 summary_font
.setWeight(QtGui
.QFont
.Bold
)
1247 policy
= QtWidgets
.QSizePolicy(
1248 QtWidgets
.QSizePolicy
.MinimumExpanding
, QtWidgets
.QSizePolicy
.Minimum
1251 self
.gravatar_label
= gravatar
.GravatarLabel(self
.context
, parent
=self
)
1253 self
.author_label
= TextLabel()
1254 self
.author_label
.setTextFormat(Qt
.RichText
)
1255 self
.author_label
.setFont(author_font
)
1256 self
.author_label
.setSizePolicy(policy
)
1257 self
.author_label
.setAlignment(Qt
.AlignBottom
)
1258 self
.author_label
.elide()
1260 self
.date_label
= TextLabel()
1261 self
.date_label
.setTextFormat(Qt
.PlainText
)
1262 self
.date_label
.setSizePolicy(policy
)
1263 self
.date_label
.setAlignment(Qt
.AlignTop
)
1264 self
.date_label
.hide()
1266 self
.summary_label
= TextLabel()
1267 self
.summary_label
.setTextFormat(Qt
.PlainText
)
1268 self
.summary_label
.setFont(summary_font
)
1269 self
.summary_label
.setSizePolicy(policy
)
1270 self
.summary_label
.setAlignment(Qt
.AlignTop
)
1271 self
.summary_label
.elide()
1273 self
.oid_label
= TextLabel()
1274 self
.oid_label
.setTextFormat(Qt
.PlainText
)
1275 self
.oid_label
.setSizePolicy(policy
)
1276 self
.oid_label
.setAlignment(Qt
.AlignTop
)
1277 self
.oid_label
.elide()
1279 self
.diff
= DiffTextEdit(context
, self
, is_commit
=is_commit
, whitespace
=False)
1280 self
.setFocusProxy(self
.diff
)
1282 self
.info_layout
= qtutils
.vbox(
1291 self
.logo_layout
= qtutils
.hbox(
1292 defs
.no_margin
, defs
.button_spacing
, self
.gravatar_label
, self
.info_layout
1294 self
.logo_layout
.setContentsMargins(defs
.margin
, 0, defs
.margin
, 0)
1296 self
.main_layout
= qtutils
.vbox(
1297 defs
.no_margin
, defs
.spacing
, self
.logo_layout
, self
.diff
1299 self
.setLayout(self
.main_layout
)
1301 self
.set_tabwidth(prefs
.tabwidth(context
))
1303 def set_tabwidth(self
, width
):
1304 self
.diff
.set_tabwidth(width
)
1306 def set_word_wrapping(self
, enabled
, update
=False):
1307 """Enable and disable word wrapping"""
1308 self
.diff
.set_word_wrapping(enabled
, update
=update
)
1310 def set_options(self
, options
):
1311 """Register an options widget"""
1312 self
.options
= options
1313 self
.diff
.set_options(options
)
1315 def start_diff_task(self
, task
):
1316 """Clear the display and start a diff-gathering task"""
1317 self
.diff
.save_scrollbar()
1318 self
.diff
.set_loading_message()
1319 self
.context
.runtask
.start(task
, result
=self
.set_diff
)
1321 def set_diff_oid(self
, oid
, filename
=None):
1322 """Set the diff from a single commit object ID"""
1323 task
= DiffInfoTask(self
.context
, oid
, filename
)
1324 self
.start_diff_task(task
)
1326 def set_diff_range(self
, start
, end
, filename
=None):
1327 task
= DiffRangeTask(self
.context
, start
+ '~', end
, filename
)
1328 self
.start_diff_task(task
)
1330 def commits_selected(self
, commits
):
1331 """Display an appropriate diff when commits are selected"""
1335 commit
= commits
[-1]
1337 email
= commit
.email
or ''
1338 summary
= commit
.summary
or ''
1339 author
= commit
.author
or ''
1340 self
.set_details(oid
, author
, email
, '', summary
)
1343 if len(commits
) > 1:
1344 start
, end
= commits
[0], commits
[-1]
1345 self
.set_diff_range(start
.oid
, end
.oid
)
1346 self
.oid_start
= start
1349 self
.set_diff_oid(oid
)
1350 self
.oid_start
= None
1353 def set_diff(self
, diff
):
1354 """Set the diff text"""
1355 self
.diff
.set_diff(diff
)
1357 def set_details(self
, oid
, author
, email
, date
, summary
):
1358 template_args
= {'author': author
, 'email': email
, 'summary': summary
}
1360 """%(author)s <"""
1361 """<a href="mailto:%(email)s">"""
1362 """%(email)s</a>>""" % template_args
1364 author_template
= '%(author)s <%(email)s>' % template_args
1366 self
.date_label
.set_text(date
)
1367 self
.date_label
.setVisible(bool(date
))
1368 self
.oid_label
.set_text(oid
)
1369 self
.author_label
.set_template(author_text
, author_template
)
1370 self
.summary_label
.set_text(summary
)
1371 self
.gravatar_label
.set_email(email
)
1374 self
.date_label
.set_text('')
1375 self
.oid_label
.set_text('')
1376 self
.author_label
.set_text('')
1377 self
.summary_label
.set_text('')
1378 self
.gravatar_label
.clear()
1381 def files_selected(self
, filenames
):
1382 """Update the view when a filename is selected"""
1385 oid_start
= self
.oid_start
1386 oid_end
= self
.oid_end
1387 if oid_start
and oid_end
:
1388 self
.set_diff_range(oid_start
.oid
, oid_end
.oid
, filename
=filenames
[0])
1390 self
.set_diff_oid(self
.oid
, filename
=filenames
[0])
1393 class TextLabel(QtWidgets
.QLabel
):
1394 def __init__(self
, parent
=None):
1395 QtWidgets
.QLabel
.__init
__(self
, parent
)
1396 self
.setTextInteractionFlags(
1397 Qt
.TextSelectableByMouse | Qt
.LinksAccessibleByMouse
1403 self
._metrics
= QtGui
.QFontMetrics(self
.font())
1404 self
.setOpenExternalLinks(True)
1409 def set_text(self
, text
):
1410 self
.set_template(text
, text
)
1412 def set_template(self
, text
, template
):
1413 self
._display
= text
1415 self
._template
= template
1416 self
.update_text(self
.width())
1417 self
.setText(self
._display
)
1419 def update_text(self
, width
):
1420 self
._display
= self
._text
1423 text
= self
._metrics
.elidedText(self
._template
, Qt
.ElideRight
, width
- 2)
1424 if text
!= self
._template
:
1425 self
._display
= text
1428 def setFont(self
, font
):
1429 self
._metrics
= QtGui
.QFontMetrics(font
)
1430 QtWidgets
.QLabel
.setFont(self
, font
)
1432 def resizeEvent(self
, event
):
1434 self
.update_text(event
.size().width())
1435 with qtutils
.BlockSignals(self
):
1436 self
.setText(self
._display
)
1437 QtWidgets
.QLabel
.resizeEvent(self
, event
)
1440 class DiffInfoTask(qtutils
.Task
):
1441 """Gather diffs for a single commit"""
1443 def __init__(self
, context
, oid
, filename
):
1444 qtutils
.Task
.__init
__(self
)
1445 self
.context
= context
1447 self
.filename
= filename
1450 context
= self
.context
1452 return gitcmds
.diff_info(context
, oid
, filename
=self
.filename
)
1455 class DiffRangeTask(qtutils
.Task
):
1456 """Gather diffs for a range of commits"""
1458 def __init__(self
, context
, start
, end
, filename
):
1459 qtutils
.Task
.__init
__(self
)
1460 self
.context
= context
1463 self
.filename
= filename
1466 context
= self
.context
1467 return gitcmds
.diff_range(context
, self
.start
, self
.end
, filename
=self
.filename
)