1 from __future__
import absolute_import
, division
, print_function
, unicode_literals
2 from functools
import partial
6 from qtpy
import QtCore
8 from qtpy
import QtWidgets
9 from qtpy
.QtCore
import Qt
10 from qtpy
.QtCore
import Signal
13 from ..editpatch
import edit_patch
14 from ..interaction
import Interaction
15 from ..models
import main
16 from ..models
import prefs
17 from ..qtutils
import get
18 from .. import actions
21 from .. import diffparse
22 from .. import gitcmds
23 from .. import gravatar
24 from .. import hotkeys
27 from .. import qtutils
28 from .text
import TextDecorator
29 from .text
import VimHintedPlainTextEdit
31 from . import imageview
34 class DiffSyntaxHighlighter(QtGui
.QSyntaxHighlighter
):
35 """Implements the diff syntax highlighting"""
40 DIFF_FILE_HEADER_STATE
= 2
45 DIFF_FILE_HEADER_START_RGX
= re
.compile(r
'diff --git a/.* b/.*')
46 DIFF_HUNK_HEADER_RGX
= re
.compile(
47 r
'(?:@@ -[0-9,]+ \+[0-9,]+ @@)|(?:@@@ (?:-[0-9,]+ ){2}\+[0-9,]+ @@@)'
49 BAD_WHITESPACE_RGX
= re
.compile(r
'\s+$')
51 def __init__(self
, context
, doc
, whitespace
=True, is_commit
=False):
52 QtGui
.QSyntaxHighlighter
.__init
__(self
, doc
)
53 self
.whitespace
= whitespace
55 self
.is_commit
= is_commit
57 QPalette
= QtGui
.QPalette
60 disabled
= palette
.color(QPalette
.Disabled
, QPalette
.Text
)
61 header
= qtutils
.rgb_hex(disabled
)
63 dark
= palette
.color(QPalette
.Base
).lightnessF() < 0.5
65 self
.color_text
= qtutils
.RGB(cfg
.color('text', '030303'))
66 self
.color_add
= qtutils
.RGB(cfg
.color('add', '77aa77' if dark
else 'd2ffe4'))
67 self
.color_remove
= qtutils
.RGB(
68 cfg
.color('remove', 'aa7777' if dark
else 'fee0e4')
70 self
.color_header
= qtutils
.RGB(cfg
.color('header', header
))
72 self
.diff_header_fmt
= qtutils
.make_format(fg
=self
.color_header
)
73 self
.bold_diff_header_fmt
= qtutils
.make_format(fg
=self
.color_header
, bold
=True)
75 self
.diff_add_fmt
= qtutils
.make_format(fg
=self
.color_text
, bg
=self
.color_add
)
76 self
.diff_remove_fmt
= qtutils
.make_format(
77 fg
=self
.color_text
, bg
=self
.color_remove
79 self
.bad_whitespace_fmt
= qtutils
.make_format(bg
=Qt
.red
)
80 self
.setCurrentBlockState(self
.INITIAL_STATE
)
82 def set_enabled(self
, enabled
):
83 self
.enabled
= enabled
85 def highlightBlock(self
, text
):
86 if not self
.enabled
or not text
:
88 # Aliases for quick local access
89 initial_state
= self
.INITIAL_STATE
90 default_state
= self
.DEFAULT_STATE
91 diff_state
= self
.DIFF_STATE
92 diffstat_state
= self
.DIFFSTAT_STATE
93 diff_file_header_state
= self
.DIFF_FILE_HEADER_STATE
94 submodule_state
= self
.SUBMODULE_STATE
95 end_state
= self
.END_STATE
97 diff_file_header_start_rgx
= self
.DIFF_FILE_HEADER_START_RGX
98 diff_hunk_header_rgx
= self
.DIFF_HUNK_HEADER_RGX
99 bad_whitespace_rgx
= self
.BAD_WHITESPACE_RGX
101 diff_header_fmt
= self
.diff_header_fmt
102 bold_diff_header_fmt
= self
.bold_diff_header_fmt
103 diff_add_fmt
= self
.diff_add_fmt
104 diff_remove_fmt
= self
.diff_remove_fmt
105 bad_whitespace_fmt
= self
.bad_whitespace_fmt
107 state
= self
.previousBlockState()
108 if state
== initial_state
:
109 if text
.startswith('Submodule '):
110 state
= submodule_state
111 elif text
.startswith('diff --git '):
112 state
= diffstat_state
114 state
= default_state
116 state
= diffstat_state
118 if state
== diffstat_state
:
119 if diff_file_header_start_rgx
.match(text
):
120 state
= diff_file_header_state
121 self
.setFormat(0, len(text
), diff_header_fmt
)
122 elif diff_hunk_header_rgx
.match(text
):
124 self
.setFormat(0, len(text
), bold_diff_header_fmt
)
127 self
.setFormat(0, i
, bold_diff_header_fmt
)
128 self
.setFormat(i
, len(text
) - i
, diff_header_fmt
)
130 self
.setFormat(0, len(text
), diff_header_fmt
)
131 elif state
== diff_file_header_state
:
132 if diff_hunk_header_rgx
.match(text
):
134 self
.setFormat(0, len(text
), bold_diff_header_fmt
)
136 self
.setFormat(0, len(text
), diff_header_fmt
)
137 elif state
== diff_state
:
138 if diff_file_header_start_rgx
.match(text
):
139 state
= diff_file_header_state
140 self
.setFormat(0, len(text
), diff_header_fmt
)
141 elif diff_hunk_header_rgx
.match(text
):
142 self
.setFormat(0, len(text
), bold_diff_header_fmt
)
143 elif text
.startswith('-'):
147 self
.setFormat(0, len(text
), diff_remove_fmt
)
148 elif text
.startswith('+'):
149 self
.setFormat(0, len(text
), diff_add_fmt
)
151 m
= bad_whitespace_rgx
.search(text
)
154 self
.setFormat(i
, len(text
) - i
, bad_whitespace_fmt
)
156 self
.setCurrentBlockState(state
)
159 # pylint: disable=too-many-ancestors
160 class DiffTextEdit(VimHintedPlainTextEdit
):
161 """A textedit for interacting with diff text"""
164 self
, context
, parent
, is_commit
=False, whitespace
=True, numbers
=False
166 VimHintedPlainTextEdit
.__init
__(self
, context
, '', parent
=parent
)
167 # Diff/patch syntax highlighter
168 self
.highlighter
= DiffSyntaxHighlighter(
169 context
, self
.document(), is_commit
=is_commit
, whitespace
=whitespace
172 self
.numbers
= DiffLineNumbers(context
, self
)
176 self
.scrollvalue
= None
178 self
.copy_diff_action
= qtutils
.add_action(
184 self
.copy_diff_action
.setIcon(icons
.copy())
185 self
.copy_diff_action
.setEnabled(False)
186 self
.menu_actions
.append(self
.copy_diff_action
)
188 # pylint: disable=no-member
189 self
.cursorPositionChanged
.connect(self
._cursor
_changed
, Qt
.QueuedConnection
)
190 self
.selectionChanged
.connect(self
._selection
_changed
, Qt
.QueuedConnection
)
192 def setFont(self
, font
):
193 """Override setFont() so that we can use a custom "block" cursor"""
194 super(DiffTextEdit
, self
).setFont(font
)
195 if prefs
.block_cursor(self
.context
):
196 metrics
= QtGui
.QFontMetrics(font
)
197 width
= metrics
.width('M')
198 self
.setCursorWidth(width
)
200 def _cursor_changed(self
):
201 """Update the line number display when the cursor changes"""
202 line_number
= max(0, self
.textCursor().blockNumber())
204 self
.numbers
.set_highlighted(line_number
)
206 def _selection_changed(self
):
207 """Respond to selection changes"""
208 selected
= bool(self
.selected_text())
209 self
.copy_diff_action
.setEnabled(selected
)
211 def resizeEvent(self
, event
):
212 super(DiffTextEdit
, self
).resizeEvent(event
)
214 self
.numbers
.refresh_size()
216 def save_scrollbar(self
):
217 """Save the scrollbar value, but only on the first call"""
218 if self
.scrollvalue
is None:
219 scrollbar
= self
.verticalScrollBar()
221 scrollvalue
= get(scrollbar
)
224 self
.scrollvalue
= scrollvalue
226 def restore_scrollbar(self
):
227 """Restore the scrollbar and clear state"""
228 scrollbar
= self
.verticalScrollBar()
229 scrollvalue
= self
.scrollvalue
230 if scrollbar
and scrollvalue
is not None:
231 scrollbar
.setValue(scrollvalue
)
232 self
.scrollvalue
= None
234 def set_loading_message(self
):
235 """Add a pending loading message in the diff view"""
236 self
.hint
.set_value('+++ ' + N_('Loading...'))
239 def set_diff(self
, diff
):
240 """Set the diff text, but save the scrollbar"""
241 diff
= diff
.rstrip('\n') # diffs include two empty newlines
242 self
.save_scrollbar()
244 self
.hint
.set_value('')
246 self
.numbers
.set_diff(diff
)
249 self
.restore_scrollbar()
251 def selected_diff_stripped(self
):
252 """Return the selected diff stripped of any diff characters"""
253 sep
, selection
= self
.selected_text_lines()
254 return sep
.join(_strip_diff(line
) for line
in selection
)
257 """Copy the selected diff text stripped of any diff prefix characters"""
258 text
= self
.selected_diff_stripped()
259 qtutils
.set_clipboard(text
)
263 """Return either CRLF or LF based on the content"""
271 def _strip_diff(value
):
272 """Remove +/-/<space> from a selection"""
273 if value
.startswith(('+', '-', ' ')):
278 class DiffLineNumbers(TextDecorator
):
279 def __init__(self
, context
, parent
):
280 TextDecorator
.__init
__(self
, parent
)
281 self
.highlight_line
= -1
283 self
.parser
= diffparse
.DiffLines()
284 self
.formatter
= diffparse
.FormatDigits()
286 self
.setFont(qtutils
.diff_font(context
))
287 self
._char
_width
= self
.fontMetrics().width('0')
289 QPalette
= QtGui
.QPalette
290 self
._palette
= palette
= self
.palette()
291 self
._base
= palette
.color(QtGui
.QPalette
.Base
)
292 self
._highlight
= palette
.color(QPalette
.Highlight
)
293 self
._highlight
.setAlphaF(0.3)
294 self
._highlight
_text
= palette
.color(QPalette
.HighlightedText
)
295 self
._window
= palette
.color(QPalette
.Window
)
296 self
._disabled
= palette
.color(QPalette
.Disabled
, QPalette
.Text
)
298 def set_diff(self
, diff
):
299 self
.lines
= self
.parser
.parse(diff
)
300 self
.formatter
.set_digits(self
.parser
.digits())
302 def width_hint(self
):
303 if not self
.isVisible():
309 extra
= 3 # one space in-between, one space after
312 extra
= 2 # one space in-between, one space after
314 digits
= parser
.digits() * columns
316 return defs
.margin
+ (self
._char
_width
* (digits
+ extra
))
318 def set_highlighted(self
, line_number
):
319 """Set the line to highlight"""
320 self
.highlight_line
= line_number
322 def current_line(self
):
324 if lines
and self
.highlight_line
>= 0:
325 # Find the next valid line
326 for i
in range(self
.highlight_line
, len(lines
)):
327 # take the "new" line number: last value in tuple
328 line_number
= lines
[i
][-1]
332 # Find the previous valid line
333 for i
in range(self
.highlight_line
- 1, -1, -1):
334 # take the "new" line number: last value in tuple
336 line_number
= lines
[i
][-1]
341 def paintEvent(self
, event
):
342 """Paint the line number"""
346 painter
= QtGui
.QPainter(self
)
347 painter
.fillRect(event
.rect(), self
._base
)
350 content_offset
= editor
.contentOffset()
351 block
= editor
.firstVisibleBlock()
353 text_width
= width
- (defs
.margin
* 2)
354 text_flags
= Qt
.AlignRight | Qt
.AlignVCenter
355 event_rect_bottom
= event
.rect().bottom()
357 highlight_line
= self
.highlight_line
358 highlight
= self
._highlight
359 highlight_text
= self
._highlight
_text
360 disabled
= self
._disabled
364 num_lines
= len(lines
)
366 while block
.isValid():
367 block_number
= block
.blockNumber()
368 if block_number
>= num_lines
:
370 block_geom
= editor
.blockBoundingGeometry(block
)
371 rect
= block_geom
.translated(content_offset
).toRect()
372 if not block
.isVisible() or rect
.top() >= event_rect_bottom
:
375 if block_number
== highlight_line
:
376 painter
.fillRect(rect
.x(), rect
.y(), width
, rect
.height(), highlight
)
377 painter
.setPen(highlight_text
)
379 painter
.setPen(disabled
)
381 line
= lines
[block_number
]
384 text
= fmt
.value(a
, b
)
386 old
, base
, new
= line
387 text
= fmt
.merge_value(old
, base
, new
)
398 block
= block
.next() # pylint: disable=next-method-called
401 class Viewer(QtWidgets
.QFrame
):
402 """Text and image diff viewers"""
404 def __init__(self
, context
, parent
=None):
405 super(Viewer
, self
).__init
__(parent
)
407 self
.context
= context
408 self
.model
= model
= context
.model
411 self
.options
= options
= Options(self
)
412 self
.text
= DiffEditor(context
, options
, self
)
413 self
.image
= imageview
.ImageView(parent
=self
)
414 self
.image
.setFocusPolicy(Qt
.NoFocus
)
416 stack
= self
.stack
= QtWidgets
.QStackedWidget(self
)
417 stack
.addWidget(self
.text
)
418 stack
.addWidget(self
.image
)
420 self
.main_layout
= qtutils
.vbox(defs
.no_margin
, defs
.no_spacing
, self
.stack
)
421 self
.setLayout(self
.main_layout
)
424 model
.images_changed
.connect(self
.set_images
, type=Qt
.QueuedConnection
)
426 # Observe the diff type
427 model
.diff_type_changed
.connect(self
.set_diff_type
, type=Qt
.QueuedConnection
)
429 # Observe the file type
430 model
.file_type_changed
.connect(self
.set_file_type
, type=Qt
.QueuedConnection
)
432 # Observe the image mode combo box
433 options
.image_mode
.currentIndexChanged
.connect(lambda _
: self
.render())
434 options
.zoom_mode
.currentIndexChanged
.connect(lambda _
: self
.render())
436 self
.setFocusProxy(self
.text
)
438 def export_state(self
, state
):
439 state
['show_diff_line_numbers'] = self
.options
.show_line_numbers
.isChecked()
440 state
['image_diff_mode'] = self
.options
.image_mode
.currentIndex()
441 state
['image_zoom_mode'] = self
.options
.zoom_mode
.currentIndex()
442 state
['word_wrap'] = self
.options
.enable_word_wrapping
.isChecked()
445 def apply_state(self
, state
):
446 diff_numbers
= bool(state
.get('show_diff_line_numbers', False))
447 self
.set_line_numbers(diff_numbers
, update
=True)
449 image_mode
= utils
.asint(state
.get('image_diff_mode', 0))
450 self
.options
.image_mode
.set_index(image_mode
)
452 zoom_mode
= utils
.asint(state
.get('image_zoom_mode', 0))
453 self
.options
.zoom_mode
.set_index(zoom_mode
)
455 word_wrap
= bool(state
.get('word_wrap', True))
456 self
.set_word_wrapping(word_wrap
, update
=True)
459 def set_diff_type(self
, diff_type
):
460 """Manage the image and text diff views when selection changes"""
461 # The "diff type" is whether the diff viewer is displaying an image.
462 self
.options
.set_diff_type(diff_type
)
463 if diff_type
== main
.Types
.IMAGE
:
464 self
.stack
.setCurrentWidget(self
.image
)
467 self
.stack
.setCurrentWidget(self
.text
)
469 def set_file_type(self
, file_type
):
470 """Manage the diff options when the file type changes"""
471 # The "file type" is whether the file itself is an image.
472 self
.options
.set_file_type(file_type
)
474 def update_options(self
):
475 """Emit a signal indicating that options have changed"""
476 self
.text
.update_options()
478 def set_line_numbers(self
, enabled
, update
=False):
479 """Enable/disable line numbers in the text widget"""
480 self
.text
.set_line_numbers(enabled
, update
=update
)
482 def set_word_wrapping(self
, enabled
, update
=False):
483 """Enable/disable word wrapping in the text widget"""
484 self
.text
.set_word_wrapping(enabled
, update
=update
)
487 self
.image
.pixmap
= QtGui
.QPixmap()
491 for (image
, unlink
) in self
.images
:
492 if unlink
and core
.exists(image
):
496 def set_images(self
, images
):
503 # In order to comp, we first have to load all the images
504 all_pixmaps
= [QtGui
.QPixmap(image
[0]) for image
in images
]
505 pixmaps
= [pixmap
for pixmap
in all_pixmaps
if not pixmap
.isNull()]
510 self
.pixmaps
= pixmaps
518 mode
= self
.options
.image_mode
.currentIndex()
519 if mode
== self
.options
.SIDE_BY_SIDE
:
520 image
= self
.render_side_by_side()
521 elif mode
== self
.options
.DIFF
:
522 image
= self
.render_diff()
523 elif mode
== self
.options
.XOR
:
524 image
= self
.render_xor()
525 elif mode
== self
.options
.PIXEL_XOR
:
526 image
= self
.render_pixel_xor()
528 image
= self
.render_side_by_side()
530 image
= QtGui
.QPixmap()
531 self
.image
.pixmap
= image
534 zoom_mode
= self
.options
.zoom_mode
.currentIndex()
535 zoom_factor
= self
.options
.zoom_factors
[zoom_mode
][1]
536 if zoom_factor
> 0.0:
537 self
.image
.resetTransform()
538 self
.image
.scale(zoom_factor
, zoom_factor
)
539 poly
= self
.image
.mapToScene(self
.image
.viewport().rect())
540 self
.image
.last_scene_roi
= poly
.boundingRect()
542 def render_side_by_side(self
):
543 # Side-by-side lineup comp
544 pixmaps
= self
.pixmaps
545 width
= sum(pixmap
.width() for pixmap
in pixmaps
)
546 height
= max(pixmap
.height() for pixmap
in pixmaps
)
547 image
= create_image(width
, height
)
550 painter
= create_painter(image
)
552 for pixmap
in pixmaps
:
553 painter
.drawPixmap(x
, 0, pixmap
)
559 def render_comp(self
, comp_mode
):
560 # Get the max size to use as the render canvas
561 pixmaps
= self
.pixmaps
562 if len(pixmaps
) == 1:
565 width
= max(pixmap
.width() for pixmap
in pixmaps
)
566 height
= max(pixmap
.height() for pixmap
in pixmaps
)
567 image
= create_image(width
, height
)
569 painter
= create_painter(image
)
570 for pixmap
in (pixmaps
[0], pixmaps
[-1]):
571 x
= (width
- pixmap
.width()) // 2
572 y
= (height
- pixmap
.height()) // 2
573 painter
.drawPixmap(x
, y
, pixmap
)
574 painter
.setCompositionMode(comp_mode
)
579 def render_diff(self
):
580 comp_mode
= QtGui
.QPainter
.CompositionMode_Difference
581 return self
.render_comp(comp_mode
)
583 def render_xor(self
):
584 comp_mode
= QtGui
.QPainter
.CompositionMode_Xor
585 return self
.render_comp(comp_mode
)
587 def render_pixel_xor(self
):
588 comp_mode
= QtGui
.QPainter
.RasterOp_SourceXorDestination
589 return self
.render_comp(comp_mode
)
592 def create_image(width
, height
):
593 size
= QtCore
.QSize(width
, height
)
594 image
= QtGui
.QImage(size
, QtGui
.QImage
.Format_ARGB32_Premultiplied
)
595 image
.fill(Qt
.transparent
)
599 def create_painter(image
):
600 painter
= QtGui
.QPainter(image
)
601 painter
.fillRect(image
.rect(), Qt
.transparent
)
605 class Options(QtWidgets
.QWidget
):
606 """Provide the options widget used by the editor
608 Actions are registered on the parent widget.
612 # mode combobox indexes
618 def __init__(self
, parent
):
619 super(Options
, self
).__init
__(parent
)
622 self
.ignore_space_at_eol
= self
.add_option(
623 N_('Ignore changes in whitespace at EOL')
626 self
.ignore_space_change
= self
.add_option(
627 N_('Ignore changes in amount of whitespace')
630 self
.ignore_all_space
= self
.add_option(N_('Ignore all whitespace'))
632 self
.function_context
= self
.add_option(
633 N_('Show whole surrounding functions of changes')
636 self
.show_line_numbers
= qtutils
.add_action_bool(
637 self
, N_('Show line numbers'), self
.set_line_numbers
, True
639 self
.enable_word_wrapping
= qtutils
.add_action_bool(
640 self
, N_('Enable word wrapping'), self
.set_word_wrapping
, True
643 self
.options
= qtutils
.create_action_button(
644 tooltip
=N_('Diff Options'), icon
=icons
.configure()
647 self
.toggle_image_diff
= qtutils
.create_action_button(
648 tooltip
=N_('Toggle image diff'), icon
=icons
.visualize()
650 self
.toggle_image_diff
.hide()
652 self
.image_mode
= qtutils
.combo(
653 [N_('Side by side'), N_('Diff'), N_('XOR'), N_('Pixel XOR')]
656 self
.zoom_factors
= (
657 (N_('Zoom to Fit'), 0.0),
665 zoom_modes
= [factor
[0] for factor
in self
.zoom_factors
]
666 self
.zoom_mode
= qtutils
.combo(zoom_modes
, parent
=self
)
668 self
.menu
= menu
= qtutils
.create_menu(N_('Diff Options'), self
.options
)
669 self
.options
.setMenu(menu
)
670 menu
.addAction(self
.ignore_space_at_eol
)
671 menu
.addAction(self
.ignore_space_change
)
672 menu
.addAction(self
.ignore_all_space
)
674 menu
.addAction(self
.function_context
)
675 menu
.addAction(self
.show_line_numbers
)
677 menu
.addAction(self
.enable_word_wrapping
)
680 layout
= qtutils
.hbox(
686 self
.toggle_image_diff
,
688 self
.setLayout(layout
)
691 self
.image_mode
.setFocusPolicy(Qt
.NoFocus
)
692 self
.zoom_mode
.setFocusPolicy(Qt
.NoFocus
)
693 self
.options
.setFocusPolicy(Qt
.NoFocus
)
694 self
.toggle_image_diff
.setFocusPolicy(Qt
.NoFocus
)
695 self
.setFocusPolicy(Qt
.NoFocus
)
697 def set_file_type(self
, file_type
):
698 """Set whether we are viewing an image file type"""
699 is_image
= file_type
== main
.Types
.IMAGE
700 self
.toggle_image_diff
.setVisible(is_image
)
702 def set_diff_type(self
, diff_type
):
703 """Toggle between image and text diffs"""
704 is_text
= diff_type
== main
.Types
.TEXT
705 is_image
= diff_type
== main
.Types
.IMAGE
706 self
.options
.setVisible(is_text
)
707 self
.image_mode
.setVisible(is_image
)
708 self
.zoom_mode
.setVisible(is_image
)
710 self
.toggle_image_diff
.setIcon(icons
.diff())
712 self
.toggle_image_diff
.setIcon(icons
.visualize())
714 def add_option(self
, title
):
715 """Add a diff option which calls update_options() on change"""
716 action
= qtutils
.add_action(self
, title
, self
.update_options
)
717 action
.setCheckable(True)
720 def update_options(self
):
721 """Update diff options in response to UI events"""
722 space_at_eol
= get(self
.ignore_space_at_eol
)
723 space_change
= get(self
.ignore_space_change
)
724 all_space
= get(self
.ignore_all_space
)
725 function_context
= get(self
.function_context
)
726 gitcmds
.update_diff_overrides(
727 space_at_eol
, space_change
, all_space
, function_context
729 self
.widget
.update_options()
731 def set_line_numbers(self
, value
):
732 """Enable / disable line numbers"""
733 self
.widget
.set_line_numbers(value
, update
=False)
735 def set_word_wrapping(self
, value
):
736 """Respond to Qt action callbacks"""
737 self
.widget
.set_word_wrapping(value
, update
=False)
739 def hide_advanced_options(self
):
740 """Hide advanced options that are not applicable to the DiffWidget"""
741 self
.show_line_numbers
.setVisible(False)
742 self
.ignore_space_at_eol
.setVisible(False)
743 self
.ignore_space_change
.setVisible(False)
744 self
.ignore_all_space
.setVisible(False)
745 self
.function_context
.setVisible(False)
748 # pylint: disable=too-many-ancestors
749 class DiffEditor(DiffTextEdit
):
753 options_changed
= Signal()
755 def __init__(self
, context
, options
, parent
):
756 DiffTextEdit
.__init
__(self
, context
, parent
, numbers
=True)
757 self
.context
= context
758 self
.model
= model
= context
.model
759 self
.selection_model
= selection_model
= context
.selection
761 # "Diff Options" tool menu
762 self
.options
= options
764 self
.action_apply_selection
= qtutils
.add_action(
767 self
.apply_selection
,
769 hotkeys
.STAGE_DIFF_ALT
,
772 self
.action_revert_selection
= qtutils
.add_action(
773 self
, 'Revert', self
.revert_selection
, hotkeys
.REVERT
775 self
.action_revert_selection
.setIcon(icons
.undo())
777 self
.action_edit_and_apply_selection
= qtutils
.add_action(
780 partial(self
.apply_selection
, edit
=True),
781 hotkeys
.EDIT_AND_STAGE_DIFF
,
784 self
.action_edit_and_revert_selection
= qtutils
.add_action(
787 partial(self
.revert_selection
, edit
=True),
788 hotkeys
.EDIT_AND_REVERT
,
790 self
.action_edit_and_revert_selection
.setIcon(icons
.undo())
791 self
.launch_editor
= actions
.launch_editor_at_line(
792 context
, self
, hotkeys
.EDIT_SHORT
, *hotkeys
.ACCEPT
794 self
.launch_difftool
= actions
.launch_difftool(context
, self
)
795 self
.stage_or_unstage
= actions
.stage_or_unstage(context
, self
)
797 # Emit up/down signals so that they can be routed by the main widget
798 self
.move_up
= actions
.move_up(self
)
799 self
.move_down
= actions
.move_down(self
)
801 model
.diff_text_updated
.connect(self
.set_diff
, type=Qt
.QueuedConnection
)
803 selection_model
.selection_changed
.connect(
804 self
.refresh
, type=Qt
.QueuedConnection
806 # Update the selection model when the cursor changes
807 self
.cursorPositionChanged
.connect(self
._update
_line
_number
)
809 qtutils
.connect_button(options
.toggle_image_diff
, self
.toggle_diff_type
)
811 def toggle_diff_type(self
):
812 cmds
.do(cmds
.ToggleDiffType
, self
.context
)
816 s
= self
.selection_model
.selection()
818 if model
.partially_stageable():
819 item
= s
.modified
[0] if s
.modified
else None
820 if item
in model
.submodules
:
822 elif item
not in model
.unstaged_deleted
:
824 self
.action_revert_selection
.setEnabled(enabled
)
826 def set_line_numbers(self
, enabled
, update
=False):
827 """Enable/disable the diff line number display"""
828 self
.numbers
.setVisible(enabled
)
830 with qtutils
.BlockSignals(self
.options
.show_line_numbers
):
831 self
.options
.show_line_numbers
.setChecked(enabled
)
832 # Refresh the display. Not doing this results in the display not
833 # correctly displaying the line numbers widget until the text scrolls.
834 self
.set_value(self
.value())
836 def update_options(self
):
837 self
.options_changed
.emit()
839 def create_context_menu(self
, event_pos
):
840 """Override create_context_menu() to display a completely custom menu"""
841 menu
= super(DiffEditor
, self
).create_context_menu(event_pos
)
842 context
= self
.context
844 s
= self
.selection_model
.selection()
845 filename
= self
.selection_model
.filename()
847 # These menu actions will be inserted at the start of the widget.
848 current_actions
= menu
.actions()
850 add_action
= menu_actions
.append
852 if model
.stageable() or model
.unstageable():
853 if model
.stageable():
854 self
.stage_or_unstage
.setText(N_('Stage'))
855 self
.stage_or_unstage
.setIcon(icons
.add())
857 self
.stage_or_unstage
.setText(N_('Unstage'))
858 self
.stage_or_unstage
.setIcon(icons
.remove())
859 add_action(self
.stage_or_unstage
)
861 if model
.partially_stageable():
862 item
= s
.modified
[0] if s
.modified
else None
863 if item
in model
.submodules
:
864 path
= core
.abspath(item
)
865 action
= qtutils
.add_action_with_icon(
869 cmds
.run(cmds
.Stage
, context
, s
.modified
),
870 hotkeys
.STAGE_SELECTION
,
874 action
= qtutils
.add_action_with_icon(
877 N_('Launch git-cola'),
878 cmds
.run(cmds
.OpenRepo
, context
, path
),
881 elif item
not in model
.unstaged_deleted
:
882 if self
.has_selection():
883 apply_text
= N_('Stage Selected Lines')
884 edit_and_apply_text
= N_('Edit Selected Lines to Stage...')
885 revert_text
= N_('Revert Selected Lines...')
886 edit_and_revert_text
= N_('Edit Selected Lines to Revert...')
888 apply_text
= N_('Stage Diff Hunk')
889 edit_and_apply_text
= N_('Edit Diff Hunk to Stage...')
890 revert_text
= N_('Revert Diff Hunk...')
891 edit_and_revert_text
= N_('Edit Diff Hunk to Revert...')
893 self
.action_apply_selection
.setText(apply_text
)
894 self
.action_apply_selection
.setIcon(icons
.add())
895 add_action(self
.action_apply_selection
)
897 self
.action_edit_and_apply_selection
.setText(edit_and_apply_text
)
898 self
.action_edit_and_apply_selection
.setIcon(icons
.add())
899 add_action(self
.action_edit_and_apply_selection
)
901 self
.action_revert_selection
.setText(revert_text
)
902 add_action(self
.action_revert_selection
)
904 self
.action_edit_and_revert_selection
.setText(edit_and_revert_text
)
905 add_action(self
.action_edit_and_revert_selection
)
907 if s
.staged
and model
.unstageable():
909 if item
in model
.submodules
:
910 path
= core
.abspath(item
)
911 action
= qtutils
.add_action_with_icon(
915 cmds
.run(cmds
.Unstage
, context
, s
.staged
),
916 hotkeys
.STAGE_SELECTION
,
920 qtutils
.add_action_with_icon(
923 N_('Launch git-cola'),
924 cmds
.run(cmds
.OpenRepo
, context
, path
),
928 elif item
not in model
.staged_deleted
:
929 if self
.has_selection():
930 apply_text
= N_('Unstage Selected Lines')
931 edit_and_apply_text
= N_('Edit Selected Lines to Unstage...')
933 apply_text
= N_('Unstage Diff Hunk')
934 edit_and_apply_text
= N_('Edit Diff Hunk to Unstage...')
936 self
.action_apply_selection
.setText(apply_text
)
937 self
.action_apply_selection
.setIcon(icons
.remove())
938 add_action(self
.action_apply_selection
)
940 self
.action_edit_and_apply_selection
.setText(edit_and_apply_text
)
941 self
.action_edit_and_apply_selection
.setIcon(icons
.remove())
942 add_action(self
.action_edit_and_apply_selection
)
944 if model
.stageable() or model
.unstageable():
945 # Do not show the "edit" action when the file does not exist.
946 # Untracked files exist by definition.
947 if filename
and core
.exists(filename
):
948 add_action(qtutils
.menu_separator(menu
))
949 add_action(self
.launch_editor
)
951 # Removed files can still be diffed.
952 add_action(self
.launch_difftool
)
954 # Add the Previous/Next File actions, which improves discoverability
955 # of their associated shortcuts
956 add_action(qtutils
.menu_separator(menu
))
957 add_action(self
.move_up
)
958 add_action(self
.move_down
)
959 add_action(qtutils
.menu_separator(menu
))
962 first_action
= current_actions
[0]
965 menu
.insertActions(first_action
, menu_actions
)
969 def mousePressEvent(self
, event
):
970 if event
.button() == Qt
.RightButton
:
971 # Intercept right-click to move the cursor to the current position.
972 # setTextCursor() clears the selection so this is only done when
973 # nothing is selected.
974 if not self
.has_selection():
975 cursor
= self
.cursorForPosition(event
.pos())
976 self
.setTextCursor(cursor
)
978 return super(DiffEditor
, self
).mousePressEvent(event
)
980 def setPlainText(self
, text
):
981 """setPlainText(str) while retaining scrollbar positions"""
984 highlight
= mode
not in (
987 model
.mode_untracked
,
989 self
.highlighter
.set_enabled(highlight
)
991 scrollbar
= self
.verticalScrollBar()
993 scrollvalue
= get(scrollbar
)
1000 DiffTextEdit
.setPlainText(self
, text
)
1002 if scrollbar
and scrollvalue
is not None:
1003 scrollbar
.setValue(scrollvalue
)
1005 def selected_lines(self
):
1006 cursor
= self
.textCursor()
1007 selection_start
= cursor
.selectionStart()
1008 selection_end
= max(selection_start
, cursor
.selectionEnd() - 1)
1015 for line_idx
, line
in enumerate(get(self
).splitlines()):
1016 line_end
= line_start
+ len(line
)
1017 if line_start
<= selection_start
<= line_end
:
1018 first_line_idx
= line_idx
1019 if line_start
<= selection_end
<= line_end
:
1020 last_line_idx
= line_idx
1022 line_start
= line_end
+ 1
1024 if first_line_idx
== -1:
1025 first_line_idx
= line_idx
1027 if last_line_idx
== -1:
1028 last_line_idx
= line_idx
1030 return first_line_idx
, last_line_idx
1032 def selected_text_lines(self
):
1033 """Return selected lines and the CRLF / LF separator"""
1034 first_line_idx
, last_line_idx
= self
.selected_lines()
1036 sep
= _get_sep(text
)
1038 for line_idx
, line
in enumerate(text
.split(sep
)):
1039 if first_line_idx
<= line_idx
<= last_line_idx
:
1043 def apply_selection(self
, *, edit
=False):
1045 s
= self
.selection_model
.single_selection()
1046 if model
.partially_stageable() and (s
.modified
or s
.untracked
):
1047 self
.process_diff_selection(edit
=edit
)
1048 elif model
.unstageable():
1049 self
.process_diff_selection(reverse
=True, edit
=edit
)
1051 def revert_selection(self
, *, edit
=False):
1052 """Destructively revert selected lines or hunk from a worktree file."""
1055 if self
.has_selection():
1056 title
= N_('Revert Selected Lines?')
1057 ok_text
= N_('Revert Selected Lines')
1059 title
= N_('Revert Diff Hunk?')
1060 ok_text
= N_('Revert Diff Hunk')
1062 if not Interaction
.confirm(
1065 'This operation drops uncommitted changes.\n'
1066 'These changes cannot be recovered.'
1068 N_('Revert the uncommitted changes?'),
1074 self
.process_diff_selection(reverse
=True, apply_to_worktree
=True, edit
=edit
)
1076 def extract_patch(self
, reverse
=False):
1077 first_line_idx
, last_line_idx
= self
.selected_lines()
1078 patch
= diffparse
.Patch
.parse(self
.model
.filename
, self
.model
.diff_text
)
1079 if self
.has_selection():
1080 return patch
.extract_subset(first_line_idx
, last_line_idx
, reverse
=reverse
)
1082 return patch
.extract_hunk(first_line_idx
, reverse
=reverse
)
1084 def patch_encoding(self
):
1085 if isinstance(self
.model
.diff_text
, core
.UStr
):
1086 # original encoding must prevail
1087 return self
.model
.diff_text
.encoding
1089 return self
.context
.cfg
.file_encoding(self
.model
.filename
)
1091 def process_diff_selection(
1092 self
, reverse
=False, apply_to_worktree
=False, edit
=False
1094 """Implement un/staging of the selected line(s) or hunk."""
1095 if self
.selection_model
.is_empty():
1097 patch
= self
.extract_patch(reverse
)
1098 if not patch
.has_changes():
1100 patch_encoding
= self
.patch_encoding()
1108 apply_to_worktree
=apply_to_worktree
,
1110 if not patch
.has_changes():
1121 def _update_line_number(self
):
1122 """Update the selection model when the cursor changes"""
1123 self
.selection_model
.line_number
= self
.numbers
.current_line()
1126 class DiffWidget(QtWidgets
.QWidget
):
1127 """Display commit metadata and text diffs"""
1129 def __init__(self
, context
, parent
, is_commit
=False, options
=None):
1130 QtWidgets
.QWidget
.__init
__(self
, parent
)
1132 self
.context
= context
1134 self
.oid_start
= None
1136 self
.options
= options
1138 author_font
= QtGui
.QFont(self
.font())
1139 author_font
.setPointSize(int(author_font
.pointSize() * 1.1))
1141 summary_font
= QtGui
.QFont(author_font
)
1142 summary_font
.setWeight(QtGui
.QFont
.Bold
)
1144 policy
= QtWidgets
.QSizePolicy(
1145 QtWidgets
.QSizePolicy
.MinimumExpanding
, QtWidgets
.QSizePolicy
.Minimum
1148 self
.gravatar_label
= gravatar
.GravatarLabel(self
.context
, parent
=self
)
1150 self
.author_label
= TextLabel()
1151 self
.author_label
.setTextFormat(Qt
.RichText
)
1152 self
.author_label
.setFont(author_font
)
1153 self
.author_label
.setSizePolicy(policy
)
1154 self
.author_label
.setAlignment(Qt
.AlignBottom
)
1155 self
.author_label
.elide()
1157 self
.date_label
= TextLabel()
1158 self
.date_label
.setTextFormat(Qt
.PlainText
)
1159 self
.date_label
.setSizePolicy(policy
)
1160 self
.date_label
.setAlignment(Qt
.AlignTop
)
1161 self
.date_label
.hide()
1163 self
.summary_label
= TextLabel()
1164 self
.summary_label
.setTextFormat(Qt
.PlainText
)
1165 self
.summary_label
.setFont(summary_font
)
1166 self
.summary_label
.setSizePolicy(policy
)
1167 self
.summary_label
.setAlignment(Qt
.AlignTop
)
1168 self
.summary_label
.elide()
1170 self
.oid_label
= TextLabel()
1171 self
.oid_label
.setTextFormat(Qt
.PlainText
)
1172 self
.oid_label
.setSizePolicy(policy
)
1173 self
.oid_label
.setAlignment(Qt
.AlignTop
)
1174 self
.oid_label
.elide()
1176 self
.diff
= DiffTextEdit(context
, self
, is_commit
=is_commit
, whitespace
=False)
1178 self
.info_layout
= qtutils
.vbox(
1187 self
.logo_layout
= qtutils
.hbox(
1188 defs
.no_margin
, defs
.button_spacing
, self
.gravatar_label
, self
.info_layout
1190 self
.logo_layout
.setContentsMargins(defs
.margin
, 0, defs
.margin
, 0)
1192 self
.main_layout
= qtutils
.vbox(
1193 defs
.no_margin
, defs
.spacing
, self
.logo_layout
, self
.diff
1195 self
.setLayout(self
.main_layout
)
1197 self
.set_tabwidth(prefs
.tabwidth(context
))
1199 def set_tabwidth(self
, width
):
1200 self
.diff
.set_tabwidth(width
)
1202 def set_word_wrapping(self
, enabled
, update
=False):
1203 """Enable and disable word wrapping"""
1204 self
.diff
.set_word_wrapping(enabled
, update
=update
)
1206 def set_options(self
, options
):
1207 """Register an options widget"""
1208 self
.options
= options
1209 self
.diff
.set_options(options
)
1211 def start_diff_task(self
, task
):
1212 """Clear the display and start a diff-gathering task"""
1213 self
.diff
.save_scrollbar()
1214 self
.diff
.set_loading_message()
1215 self
.context
.runtask
.start(task
, result
=self
.set_diff
)
1217 def set_diff_oid(self
, oid
, filename
=None):
1218 """Set the diff from a single commit object ID"""
1219 task
= DiffInfoTask(self
.context
, oid
, filename
)
1220 self
.start_diff_task(task
)
1222 def set_diff_range(self
, start
, end
, filename
=None):
1223 task
= DiffRangeTask(self
.context
, start
+ '~', end
, filename
)
1224 self
.start_diff_task(task
)
1226 def commits_selected(self
, commits
):
1227 """Display an appropriate diff when commits are selected"""
1233 email
= commit
.email
or ''
1234 summary
= commit
.summary
or ''
1235 author
= commit
.author
or ''
1236 self
.set_details(oid
, author
, email
, '', summary
)
1239 if len(commits
) > 1:
1240 start
, end
= commits
[-1], commits
[0]
1241 self
.set_diff_range(start
.oid
, end
.oid
)
1242 self
.oid_start
= start
1245 self
.set_diff_oid(oid
)
1246 self
.oid_start
= None
1249 def set_diff(self
, diff
):
1250 """Set the diff text"""
1251 self
.diff
.set_diff(diff
)
1253 def set_details(self
, oid
, author
, email
, date
, summary
):
1254 template_args
= {'author': author
, 'email': email
, 'summary': summary
}
1256 """%(author)s <"""
1257 """<a href="mailto:%(email)s">"""
1258 """%(email)s</a>>""" % template_args
1260 author_template
= '%(author)s <%(email)s>' % template_args
1262 self
.date_label
.set_text(date
)
1263 self
.date_label
.setVisible(bool(date
))
1264 self
.oid_label
.set_text(oid
)
1265 self
.author_label
.set_template(author_text
, author_template
)
1266 self
.summary_label
.set_text(summary
)
1267 self
.gravatar_label
.set_email(email
)
1270 self
.date_label
.set_text('')
1271 self
.oid_label
.set_text('')
1272 self
.author_label
.set_text('')
1273 self
.summary_label
.set_text('')
1274 self
.gravatar_label
.clear()
1277 def files_selected(self
, filenames
):
1278 """Update the view when a filename is selected"""
1281 oid_start
= self
.oid_start
1282 oid_end
= self
.oid_end
1283 if oid_start
and oid_end
:
1284 self
.set_diff_range(oid_start
.oid
, oid_end
.oid
, filename
=filenames
[0])
1286 self
.set_diff_oid(self
.oid
, filename
=filenames
[0])
1289 class TextLabel(QtWidgets
.QLabel
):
1290 def __init__(self
, parent
=None):
1291 QtWidgets
.QLabel
.__init
__(self
, parent
)
1292 self
.setTextInteractionFlags(
1293 Qt
.TextSelectableByMouse | Qt
.LinksAccessibleByMouse
1299 self
._metrics
= QtGui
.QFontMetrics(self
.font())
1300 self
.setOpenExternalLinks(True)
1305 def set_text(self
, text
):
1306 self
.set_template(text
, text
)
1308 def set_template(self
, text
, template
):
1309 self
._display
= text
1311 self
._template
= template
1312 self
.update_text(self
.width())
1313 self
.setText(self
._display
)
1315 def update_text(self
, width
):
1316 self
._display
= self
._text
1319 text
= self
._metrics
.elidedText(self
._template
, Qt
.ElideRight
, width
- 2)
1320 if text
!= self
._template
:
1321 self
._display
= text
1324 def setFont(self
, font
):
1325 self
._metrics
= QtGui
.QFontMetrics(font
)
1326 QtWidgets
.QLabel
.setFont(self
, font
)
1328 def resizeEvent(self
, event
):
1330 self
.update_text(event
.size().width())
1331 with qtutils
.BlockSignals(self
):
1332 self
.setText(self
._display
)
1333 QtWidgets
.QLabel
.resizeEvent(self
, event
)
1336 class DiffInfoTask(qtutils
.Task
):
1337 """Gather diffs for a single commit"""
1338 def __init__(self
, context
, oid
, filename
):
1339 qtutils
.Task
.__init
__(self
)
1340 self
.context
= context
1342 self
.filename
= filename
1345 context
= self
.context
1347 return gitcmds
.diff_info(context
, oid
, filename
=self
.filename
)
1350 class DiffRangeTask(qtutils
.Task
):
1351 """Gather diffs for a range of commits"""
1352 def __init__(self
, context
, start
, end
, filename
):
1353 qtutils
.Task
.__init
__(self
)
1354 self
.context
= context
1357 self
.filename
= filename
1360 context
= self
.context
1361 return gitcmds
.diff_range(context
, self
.start
, self
.end
, filename
=self
.filename
)