1 from __future__
import division
, absolute_import
, unicode_literals
5 from qtpy
import QtCore
7 from qtpy
import QtWidgets
8 from qtpy
.QtCore
import Qt
9 from qtpy
.QtCore
import Signal
12 from ..interaction
import Interaction
13 from ..models
import main
14 from ..models
import prefs
15 from ..qtutils
import get
16 from .. import actions
19 from .. import diffparse
20 from .. import gitcmds
21 from .. import gravatar
22 from .. import hotkeys
25 from .. import qtutils
26 from .text
import TextDecorator
27 from .text
import VimHintedPlainTextEdit
29 from . import imageview
32 COMMITS_SELECTED
= 'COMMITS_SELECTED'
33 FILES_SELECTED
= 'FILES_SELECTED'
36 class DiffSyntaxHighlighter(QtGui
.QSyntaxHighlighter
):
37 """Implements the diff syntax highlighting"""
42 DIFF_FILE_HEADER_STATE
= 2
47 DIFF_FILE_HEADER_START_RGX
= re
.compile(r
'diff --git a/.* b/.*')
48 DIFF_HUNK_HEADER_RGX
= re
.compile(
49 r
'(?:@@ -[0-9,]+ \+[0-9,]+ @@)|' r
'(?:@@@ (?:-[0-9,]+ ){2}\+[0-9,]+ @@@)'
51 BAD_WHITESPACE_RGX
= re
.compile(r
'\s+$')
53 def __init__(self
, context
, doc
, whitespace
=True, is_commit
=False):
54 QtGui
.QSyntaxHighlighter
.__init
__(self
, doc
)
55 self
.whitespace
= whitespace
57 self
.is_commit
= is_commit
59 QPalette
= QtGui
.QPalette
62 disabled
= palette
.color(QPalette
.Disabled
, QPalette
.Text
)
63 header
= qtutils
.rgb_hex(disabled
)
65 dark
= palette
.color(QPalette
.Base
).lightnessF() < 0.5
67 self
.color_text
= qtutils
.RGB(cfg
.color('text', '030303'))
68 self
.color_add
= qtutils
.RGB(cfg
.color('add', '77aa77' if dark
else 'd2ffe4'))
69 self
.color_remove
= qtutils
.RGB(
70 cfg
.color('remove', 'aa7777' if dark
else 'fee0e4')
72 self
.color_header
= qtutils
.RGB(cfg
.color('header', header
))
74 self
.diff_header_fmt
= qtutils
.make_format(fg
=self
.color_header
)
75 self
.bold_diff_header_fmt
= qtutils
.make_format(fg
=self
.color_header
, bold
=True)
77 self
.diff_add_fmt
= qtutils
.make_format(fg
=self
.color_text
, bg
=self
.color_add
)
78 self
.diff_remove_fmt
= qtutils
.make_format(
79 fg
=self
.color_text
, bg
=self
.color_remove
81 self
.bad_whitespace_fmt
= qtutils
.make_format(bg
=Qt
.red
)
82 self
.setCurrentBlockState(self
.INITIAL_STATE
)
84 def set_enabled(self
, enabled
):
85 self
.enabled
= enabled
87 def highlightBlock(self
, text
):
88 if not self
.enabled
or not text
:
90 # Aliases for quick local access
91 initial_state
= self
.INITIAL_STATE
92 default_state
= self
.DEFAULT_STATE
93 diff_state
= self
.DIFF_STATE
94 diffstat_state
= self
.DIFFSTAT_STATE
95 diff_file_header_state
= self
.DIFF_FILE_HEADER_STATE
96 submodule_state
= self
.SUBMODULE_STATE
97 end_state
= self
.END_STATE
99 diff_file_header_start_rgx
= self
.DIFF_FILE_HEADER_START_RGX
100 diff_hunk_header_rgx
= self
.DIFF_HUNK_HEADER_RGX
101 bad_whitespace_rgx
= self
.BAD_WHITESPACE_RGX
103 diff_header_fmt
= self
.diff_header_fmt
104 bold_diff_header_fmt
= self
.bold_diff_header_fmt
105 diff_add_fmt
= self
.diff_add_fmt
106 diff_remove_fmt
= self
.diff_remove_fmt
107 bad_whitespace_fmt
= self
.bad_whitespace_fmt
109 state
= self
.previousBlockState()
110 if state
== initial_state
:
111 if text
.startswith('Submodule '):
112 state
= submodule_state
113 elif text
.startswith('diff --git '):
114 state
= diffstat_state
116 state
= default_state
118 state
= diffstat_state
120 if state
== diffstat_state
:
121 if diff_file_header_start_rgx
.match(text
):
122 state
= diff_file_header_state
123 self
.setFormat(0, len(text
), diff_header_fmt
)
124 elif diff_hunk_header_rgx
.match(text
):
126 self
.setFormat(0, len(text
), bold_diff_header_fmt
)
129 self
.setFormat(0, i
, bold_diff_header_fmt
)
130 self
.setFormat(i
, len(text
) - i
, diff_header_fmt
)
132 self
.setFormat(0, len(text
), diff_header_fmt
)
133 elif state
== diff_file_header_state
:
134 if diff_hunk_header_rgx
.match(text
):
136 self
.setFormat(0, len(text
), bold_diff_header_fmt
)
138 self
.setFormat(0, len(text
), diff_header_fmt
)
139 elif state
== diff_state
:
140 if diff_file_header_start_rgx
.match(text
):
141 state
= diff_file_header_state
142 self
.setFormat(0, len(text
), diff_header_fmt
)
143 elif diff_hunk_header_rgx
.match(text
):
144 self
.setFormat(0, len(text
), bold_diff_header_fmt
)
145 elif text
.startswith('-'):
149 self
.setFormat(0, len(text
), diff_remove_fmt
)
150 elif text
.startswith('+'):
151 self
.setFormat(0, len(text
), diff_add_fmt
)
153 m
= bad_whitespace_rgx
.search(text
)
156 self
.setFormat(i
, len(text
) - i
, bad_whitespace_fmt
)
158 self
.setCurrentBlockState(state
)
161 # pylint: disable=too-many-ancestors
162 class DiffTextEdit(VimHintedPlainTextEdit
):
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
177 # pylint: disable=no-member
178 self
.cursorPositionChanged
.connect(self
._cursor
_changed
)
180 def _cursor_changed(self
):
181 """Update the line number display when the cursor changes"""
182 line_number
= max(0, self
.textCursor().blockNumber())
184 self
.numbers
.set_highlighted(line_number
)
186 def resizeEvent(self
, event
):
187 super(DiffTextEdit
, self
).resizeEvent(event
)
189 self
.numbers
.refresh_size()
191 def save_scrollbar(self
):
192 """Save the scrollbar value, but only on the first call"""
193 if self
.scrollvalue
is None:
194 scrollbar
= self
.verticalScrollBar()
196 scrollvalue
= get(scrollbar
)
199 self
.scrollvalue
= scrollvalue
201 def restore_scrollbar(self
):
202 """Restore the scrollbar and clear state"""
203 scrollbar
= self
.verticalScrollBar()
204 scrollvalue
= self
.scrollvalue
205 if scrollbar
and scrollvalue
is not None:
206 scrollbar
.setValue(scrollvalue
)
207 self
.scrollvalue
= None
209 def set_loading_message(self
):
210 self
.hint
.set_value('+++ ' + N_('Loading...'))
213 def set_diff(self
, diff
):
214 """Set the diff text, but save the scrollbar"""
215 diff
= diff
.rstrip('\n') # diffs include two empty newlines
216 self
.save_scrollbar()
218 self
.hint
.set_value('')
220 self
.numbers
.set_diff(diff
)
223 self
.restore_scrollbar()
226 class DiffLineNumbers(TextDecorator
):
227 def __init__(self
, context
, parent
):
228 TextDecorator
.__init
__(self
, parent
)
229 self
.highlight_line
= -1
231 self
.parser
= diffparse
.DiffLines()
232 self
.formatter
= diffparse
.FormatDigits()
234 self
.setFont(qtutils
.diff_font(context
))
235 self
._char
_width
= self
.fontMetrics().width('0')
237 QPalette
= QtGui
.QPalette
238 self
._palette
= palette
= self
.palette()
239 self
._base
= palette
.color(QtGui
.QPalette
.Base
)
240 self
._highlight
= palette
.color(QPalette
.Highlight
)
241 self
._highlight
_text
= palette
.color(QPalette
.HighlightedText
)
242 self
._window
= palette
.color(QPalette
.Window
)
243 self
._disabled
= palette
.color(QPalette
.Disabled
, QPalette
.Text
)
245 def set_diff(self
, diff
):
247 lines
= parser
.parse(diff
)
250 self
.formatter
.set_digits(self
.parser
.digits())
254 def set_lines(self
, lines
):
257 def width_hint(self
):
258 if not self
.isVisible():
264 extra
= 3 # one space in-between, one space after
267 extra
= 2 # one space in-between, one space after
270 digits
= parser
.digits() * columns
274 return defs
.margin
+ (self
._char
_width
* (digits
+ extra
))
276 def set_highlighted(self
, line_number
):
277 """Set the line to highlight"""
278 self
.highlight_line
= line_number
280 def current_line(self
):
281 if self
.lines
and self
.highlight_line
>= 0:
282 # Find the next valid line
283 for line
in self
.lines
[self
.highlight_line
:]:
284 # take the "new" line number: last value in tuple
285 line_number
= line
[-1]
289 # Find the previous valid line
290 for line
in self
.lines
[self
.highlight_line
- 1 :: -1]:
291 # take the "new" line number: last value in tuple
292 line_number
= line
[-1]
297 def paintEvent(self
, event
):
298 """Paint the line number"""
302 painter
= QtGui
.QPainter(self
)
303 painter
.fillRect(event
.rect(), self
._base
)
306 content_offset
= editor
.contentOffset()
307 block
= editor
.firstVisibleBlock()
309 event_rect_bottom
= event
.rect().bottom()
311 highlight
= self
._highlight
312 highlight
.setAlphaF(0.3)
313 highlight_text
= self
._highlight
_text
314 disabled
= self
._disabled
318 num_lines
= len(self
.lines
)
319 painter
.setPen(disabled
)
322 while block
.isValid():
323 block_number
= block
.blockNumber()
324 if block_number
>= num_lines
:
326 block_geom
= editor
.blockBoundingGeometry(block
)
327 block_top
= block_geom
.translated(content_offset
).top()
328 if not block
.isVisible() or block_top
>= event_rect_bottom
:
331 rect
= block_geom
.translated(content_offset
).toRect()
332 if block_number
== self
.highlight_line
:
333 painter
.fillRect(rect
.x(), rect
.y(), width
, rect
.height(), highlight
)
334 painter
.setPen(highlight_text
)
336 painter
.setPen(disabled
)
338 line
= lines
[block_number
]
341 text
= fmt
.value(a
, b
)
343 old
, base
, new
= line
344 text
= fmt
.merge_value(old
, base
, new
)
349 self
.width() - (defs
.margin
* 2),
351 Qt
.AlignRight | Qt
.AlignVCenter
,
355 block
= block
.next() # pylint: disable=next-method-called
358 class Viewer(QtWidgets
.QFrame
):
359 """Text and image diff viewers"""
361 images_changed
= Signal(object)
362 diff_type_changed
= Signal(object)
363 file_type_changed
= Signal(object)
365 def __init__(self
, context
, parent
=None):
366 super(Viewer
, self
).__init
__(parent
)
368 self
.context
= context
369 self
.model
= model
= context
.model
372 self
.options
= options
= Options(self
)
373 self
.text
= DiffEditor(context
, options
, self
)
374 self
.image
= imageview
.ImageView(parent
=self
)
375 self
.image
.setFocusPolicy(Qt
.NoFocus
)
377 stack
= self
.stack
= QtWidgets
.QStackedWidget(self
)
378 stack
.addWidget(self
.text
)
379 stack
.addWidget(self
.image
)
381 self
.main_layout
= qtutils
.vbox(defs
.no_margin
, defs
.no_spacing
, self
.stack
)
382 self
.setLayout(self
.main_layout
)
385 images_msg
= model
.message_images_changed
386 model
.add_observer(images_msg
, self
.images_changed
.emit
)
387 # pylint: disable=no-member
388 self
.images_changed
.connect(self
.set_images
, type=Qt
.QueuedConnection
)
390 # Observe the diff type
391 diff_type_msg
= model
.message_diff_type_changed
392 model
.add_observer(diff_type_msg
, self
.diff_type_changed
.emit
)
393 self
.diff_type_changed
.connect(self
.set_diff_type
, type=Qt
.QueuedConnection
)
395 # Observe the file type
396 file_type_msg
= model
.message_file_type_changed
397 model
.add_observer(file_type_msg
, self
.file_type_changed
.emit
)
398 self
.file_type_changed
.connect(self
.set_file_type
, type=Qt
.QueuedConnection
)
400 # Observe the image mode combo box
401 options
.image_mode
.currentIndexChanged
.connect(lambda _
: self
.render())
402 options
.zoom_mode
.currentIndexChanged
.connect(lambda _
: self
.render())
404 self
.setFocusProxy(self
.text
)
406 def export_state(self
, state
):
407 state
['show_diff_line_numbers'] = self
.options
.show_line_numbers
.isChecked()
408 state
['image_diff_mode'] = self
.options
.image_mode
.currentIndex()
409 state
['image_zoom_mode'] = self
.options
.zoom_mode
.currentIndex()
410 state
['word_wrap'] = self
.options
.enable_word_wrapping
.isChecked()
413 def apply_state(self
, state
):
414 diff_numbers
= bool(state
.get('show_diff_line_numbers', False))
415 self
.set_line_numbers(diff_numbers
, update
=True)
417 image_mode
= utils
.asint(state
.get('image_diff_mode', 0))
418 self
.options
.image_mode
.set_index(image_mode
)
420 zoom_mode
= utils
.asint(state
.get('image_zoom_mode', 0))
421 self
.options
.zoom_mode
.set_index(zoom_mode
)
423 word_wrap
= bool(state
.get('word_wrap', True))
424 self
.set_word_wrapping(word_wrap
, update
=True)
427 def set_diff_type(self
, diff_type
):
428 """Manage the image and text diff views when selection changes"""
429 # The "diff type" is whether the diff viewer is displaying an image.
430 self
.options
.set_diff_type(diff_type
)
431 if diff_type
== main
.Types
.IMAGE
:
432 self
.stack
.setCurrentWidget(self
.image
)
435 self
.stack
.setCurrentWidget(self
.text
)
437 def set_file_type(self
, file_type
):
438 """Manage the diff options when the file type changes"""
439 # The "file type" is whether the file itself is an image.
440 self
.options
.set_file_type(file_type
)
442 def set_options(self
):
443 """Emit a signal indicating that options have changed"""
444 self
.text
.set_options()
446 def set_line_numbers(self
, enabled
, update
=False):
447 """Enable/disable line numbers in the text widget"""
448 self
.text
.set_line_numbers(enabled
, update
=update
)
450 def set_word_wrapping(self
, enabled
, update
=False):
451 """Enable/disable word wrapping in the text widget"""
452 self
.text
.set_word_wrapping(enabled
, update
=update
)
455 self
.image
.pixmap
= QtGui
.QPixmap()
459 for (image
, unlink
) in self
.images
:
460 if unlink
and core
.exists(image
):
464 def set_images(self
, images
):
471 # In order to comp, we first have to load all the images
472 all_pixmaps
= [QtGui
.QPixmap(image
[0]) for image
in images
]
473 pixmaps
= [pixmap
for pixmap
in all_pixmaps
if not pixmap
.isNull()]
478 self
.pixmaps
= pixmaps
486 mode
= self
.options
.image_mode
.currentIndex()
487 if mode
== self
.options
.SIDE_BY_SIDE
:
488 image
= self
.render_side_by_side()
489 elif mode
== self
.options
.DIFF
:
490 image
= self
.render_diff()
491 elif mode
== self
.options
.XOR
:
492 image
= self
.render_xor()
493 elif mode
== self
.options
.PIXEL_XOR
:
494 image
= self
.render_pixel_xor()
496 image
= self
.render_side_by_side()
498 image
= QtGui
.QPixmap()
499 self
.image
.pixmap
= image
502 zoom_mode
= self
.options
.zoom_mode
.currentIndex()
503 zoom_factor
= self
.options
.zoom_factors
[zoom_mode
][1]
504 if zoom_factor
> 0.0:
505 self
.image
.resetTransform()
506 self
.image
.scale(zoom_factor
, zoom_factor
)
507 poly
= self
.image
.mapToScene(self
.image
.viewport().rect())
508 self
.image
.last_scene_roi
= poly
.boundingRect()
510 def render_side_by_side(self
):
511 # Side-by-side lineup comp
512 pixmaps
= self
.pixmaps
513 width
= sum([pixmap
.width() for pixmap
in pixmaps
])
514 height
= max([pixmap
.height() for pixmap
in pixmaps
])
515 image
= create_image(width
, height
)
518 painter
= create_painter(image
)
520 for pixmap
in pixmaps
:
521 painter
.drawPixmap(x
, 0, pixmap
)
527 def render_comp(self
, comp_mode
):
528 # Get the max size to use as the render canvas
529 pixmaps
= self
.pixmaps
530 if len(pixmaps
) == 1:
533 width
= max([pixmap
.width() for pixmap
in pixmaps
])
534 height
= max([pixmap
.height() for pixmap
in pixmaps
])
535 image
= create_image(width
, height
)
537 painter
= create_painter(image
)
538 for pixmap
in (pixmaps
[0], pixmaps
[-1]):
539 x
= (width
- pixmap
.width()) // 2
540 y
= (height
- pixmap
.height()) // 2
541 painter
.drawPixmap(x
, y
, pixmap
)
542 painter
.setCompositionMode(comp_mode
)
547 def render_diff(self
):
548 comp_mode
= QtGui
.QPainter
.CompositionMode_Difference
549 return self
.render_comp(comp_mode
)
551 def render_xor(self
):
552 comp_mode
= QtGui
.QPainter
.CompositionMode_Xor
553 return self
.render_comp(comp_mode
)
555 def render_pixel_xor(self
):
556 comp_mode
= QtGui
.QPainter
.RasterOp_SourceXorDestination
557 return self
.render_comp(comp_mode
)
560 def create_image(width
, height
):
561 size
= QtCore
.QSize(width
, height
)
562 image
= QtGui
.QImage(size
, QtGui
.QImage
.Format_ARGB32_Premultiplied
)
563 image
.fill(Qt
.transparent
)
567 def create_painter(image
):
568 painter
= QtGui
.QPainter(image
)
569 painter
.fillRect(image
.rect(), Qt
.transparent
)
573 class Options(QtWidgets
.QWidget
):
574 """Provide the options widget used by the editor
576 Actions are registered on the parent widget.
580 # mode combobox indexes
586 def __init__(self
, parent
):
587 super(Options
, self
).__init
__(parent
)
590 self
.ignore_space_at_eol
= self
.add_option(
591 N_('Ignore changes in whitespace at EOL')
594 self
.ignore_space_change
= self
.add_option(
595 N_('Ignore changes in amount of whitespace')
598 self
.ignore_all_space
= self
.add_option(N_('Ignore all whitespace'))
600 self
.function_context
= self
.add_option(
601 N_('Show whole surrounding functions of changes')
604 self
.show_line_numbers
= qtutils
.add_action_bool(
605 self
, N_('Show line numbers'), self
.set_line_numbers
, True
607 self
.enable_word_wrapping
= qtutils
.add_action_bool(
608 self
, N_('Enable word wrapping'), self
.set_word_wrapping
, True
611 self
.options
= qtutils
.create_action_button(
612 tooltip
=N_('Diff Options'), icon
=icons
.configure()
615 self
.toggle_image_diff
= qtutils
.create_action_button(
616 tooltip
=N_('Toggle image diff'), icon
=icons
.visualize()
618 self
.toggle_image_diff
.hide()
620 self
.image_mode
= qtutils
.combo(
621 [N_('Side by side'), N_('Diff'), N_('XOR'), N_('Pixel XOR')]
624 self
.zoom_factors
= (
625 (N_('Zoom to Fit'), 0.0),
633 zoom_modes
= [factor
[0] for factor
in self
.zoom_factors
]
634 self
.zoom_mode
= qtutils
.combo(zoom_modes
, parent
=self
)
636 self
.menu
= menu
= qtutils
.create_menu(N_('Diff Options'), self
.options
)
637 self
.options
.setMenu(menu
)
638 menu
.addAction(self
.ignore_space_at_eol
)
639 menu
.addAction(self
.ignore_space_change
)
640 menu
.addAction(self
.ignore_all_space
)
642 menu
.addAction(self
.function_context
)
643 menu
.addAction(self
.show_line_numbers
)
645 menu
.addAction(self
.enable_word_wrapping
)
648 layout
= qtutils
.hbox(
654 self
.toggle_image_diff
,
656 self
.setLayout(layout
)
659 self
.image_mode
.setFocusPolicy(Qt
.NoFocus
)
660 self
.zoom_mode
.setFocusPolicy(Qt
.NoFocus
)
661 self
.options
.setFocusPolicy(Qt
.NoFocus
)
662 self
.toggle_image_diff
.setFocusPolicy(Qt
.NoFocus
)
663 self
.setFocusPolicy(Qt
.NoFocus
)
665 def set_file_type(self
, file_type
):
666 """Set whether we are viewing an image file type"""
667 is_image
= file_type
== main
.Types
.IMAGE
668 self
.toggle_image_diff
.setVisible(is_image
)
670 def set_diff_type(self
, diff_type
):
671 """Toggle between image and text diffs"""
672 is_text
= diff_type
== main
.Types
.TEXT
673 is_image
= diff_type
== main
.Types
.IMAGE
674 self
.options
.setVisible(is_text
)
675 self
.image_mode
.setVisible(is_image
)
676 self
.zoom_mode
.setVisible(is_image
)
678 self
.toggle_image_diff
.setIcon(icons
.diff())
680 self
.toggle_image_diff
.setIcon(icons
.visualize())
682 def add_option(self
, title
):
683 """Add a diff option which calls set_options() on change"""
684 action
= qtutils
.add_action(self
, title
, self
.set_options
)
685 action
.setCheckable(True)
688 def set_options(self
):
689 """Update diff options in response to UI events"""
690 space_at_eol
= get(self
.ignore_space_at_eol
)
691 space_change
= get(self
.ignore_space_change
)
692 all_space
= get(self
.ignore_all_space
)
693 function_context
= get(self
.function_context
)
694 gitcmds
.update_diff_overrides(
695 space_at_eol
, space_change
, all_space
, function_context
697 self
.widget
.set_options()
699 def set_line_numbers(self
, value
):
700 self
.widget
.set_line_numbers(value
, update
=False)
702 def set_word_wrapping(self
, value
):
703 """Respond to Qt action callbacks"""
704 self
.widget
.set_word_wrapping(value
, update
=False)
707 # pylint: disable=too-many-ancestors
708 class DiffEditor(DiffTextEdit
):
712 options_changed
= Signal()
714 diff_text_updated
= Signal(object)
716 def __init__(self
, context
, options
, parent
):
717 DiffTextEdit
.__init
__(self
, context
, parent
, numbers
=True)
718 self
.context
= context
719 self
.model
= model
= context
.model
720 self
.selection_model
= selection_model
= context
.selection
722 # "Diff Options" tool menu
723 self
.options
= options
725 self
.action_apply_selection
= qtutils
.add_action(
726 self
, 'Apply', self
.apply_selection
, hotkeys
.STAGE_DIFF
729 self
.action_revert_selection
= qtutils
.add_action(
730 self
, 'Revert', self
.revert_selection
, hotkeys
.REVERT
732 self
.action_revert_selection
.setIcon(icons
.undo())
734 self
.launch_editor
= actions
.launch_editor_at_line(
735 context
, self
, hotkeys
.EDIT_SHORT
, *hotkeys
.ACCEPT
737 self
.launch_difftool
= actions
.launch_difftool(context
, self
)
738 self
.stage_or_unstage
= actions
.stage_or_unstage(context
, self
)
740 # Emit up/down signals so that they can be routed by the main widget
741 self
.move_up
= actions
.move_up(self
)
742 self
.move_down
= actions
.move_down(self
)
744 diff_text_updated
= model
.message_diff_text_updated
745 model
.add_observer(diff_text_updated
, self
.diff_text_updated
.emit
)
746 self
.diff_text_updated
.connect(self
.set_diff
, type=Qt
.QueuedConnection
)
748 selection_model
.add_observer(
749 selection_model
.message_selection_changed
, self
.updated
.emit
751 # pylint: disable=no-member
752 self
.updated
.connect(self
.refresh
, type=Qt
.QueuedConnection
)
753 # Update the selection model when the cursor changes
754 self
.cursorPositionChanged
.connect(self
._update
_line
_number
)
756 qtutils
.connect_button(options
.toggle_image_diff
, self
.toggle_diff_type
)
758 def toggle_diff_type(self
):
759 cmds
.do(cmds
.ToggleDiffType
, self
.context
)
763 s
= self
.selection_model
.selection()
765 if model
.stageable():
766 item
= s
.modified
[0] if s
.modified
else None
767 if item
in model
.submodules
:
769 elif item
not in model
.unstaged_deleted
:
771 self
.action_revert_selection
.setEnabled(enabled
)
773 def set_line_numbers(self
, enabled
, update
=False):
774 """Enable/disable the diff line number display"""
775 self
.numbers
.setVisible(enabled
)
777 with qtutils
.BlockSignals(self
.options
.show_line_numbers
):
778 self
.options
.show_line_numbers
.setChecked(enabled
)
779 # Refresh the display. Not doing this results in the display not
780 # correctly displaying the line numbers widget until the text scrolls.
781 self
.set_value(self
.value())
783 def set_word_wrapping(self
, enabled
, update
=False):
784 """Enable/disable word wrapping"""
786 with qtutils
.BlockSignals(self
.options
.enable_word_wrapping
):
787 self
.options
.enable_word_wrapping
.setChecked(enabled
)
789 self
.setWordWrapMode(QtGui
.QTextOption
.WordWrap
)
790 self
.setLineWrapMode(QtWidgets
.QPlainTextEdit
.WidgetWidth
)
792 self
.setWordWrapMode(QtGui
.QTextOption
.NoWrap
)
793 self
.setLineWrapMode(QtWidgets
.QPlainTextEdit
.NoWrap
)
795 def set_options(self
):
796 self
.options_changed
.emit()
799 def contextMenuEvent(self
, event
):
800 """Create the context menu for the diff display."""
801 menu
= qtutils
.create_menu(N_('Actions'), self
)
802 context
= self
.context
804 s
= self
.selection_model
.selection()
805 filename
= self
.selection_model
.filename()
807 if model
.stageable() or model
.unstageable():
808 if model
.stageable():
809 self
.stage_or_unstage
.setText(N_('Stage'))
811 self
.stage_or_unstage
.setText(N_('Unstage'))
812 menu
.addAction(self
.stage_or_unstage
)
814 if model
.stageable():
815 item
= s
.modified
[0] if s
.modified
else None
816 if item
in model
.submodules
:
817 path
= core
.abspath(item
)
818 action
= menu
.addAction(
821 cmds
.run(cmds
.Stage
, context
, s
.modified
),
823 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
826 N_('Launch git-cola'),
827 cmds
.run(cmds
.OpenRepo
, context
, path
),
829 elif item
not in model
.unstaged_deleted
:
830 if self
.has_selection():
831 apply_text
= N_('Stage Selected Lines')
832 revert_text
= N_('Revert Selected Lines...')
834 apply_text
= N_('Stage Diff Hunk')
835 revert_text
= N_('Revert Diff Hunk...')
837 self
.action_apply_selection
.setText(apply_text
)
838 self
.action_apply_selection
.setIcon(icons
.add())
840 self
.action_revert_selection
.setText(revert_text
)
842 menu
.addAction(self
.action_apply_selection
)
843 menu
.addAction(self
.action_revert_selection
)
845 if s
.staged
and model
.unstageable():
847 if item
in model
.submodules
:
848 path
= core
.abspath(item
)
849 action
= menu
.addAction(
852 cmds
.run(cmds
.Unstage
, context
, s
.staged
),
854 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
857 N_('Launch git-cola'),
858 cmds
.run(cmds
.OpenRepo
, context
, path
),
860 elif item
not in model
.staged_deleted
:
861 if self
.has_selection():
862 apply_text
= N_('Unstage Selected Lines')
864 apply_text
= N_('Unstage Diff Hunk')
866 self
.action_apply_selection
.setText(apply_text
)
867 self
.action_apply_selection
.setIcon(icons
.remove())
868 menu
.addAction(self
.action_apply_selection
)
870 if model
.stageable() or model
.unstageable():
871 # Do not show the "edit" action when the file does not exist.
872 # Untracked files exist by definition.
873 if filename
and core
.exists(filename
):
875 menu
.addAction(self
.launch_editor
)
877 # Removed files can still be diffed.
878 menu
.addAction(self
.launch_difftool
)
880 # Add the Previous/Next File actions, which improves discoverability
881 # of their associated shortcuts
883 menu
.addAction(self
.move_up
)
884 menu
.addAction(self
.move_down
)
887 action
= menu
.addAction(icons
.copy(), N_('Copy'), self
.copy
)
888 action
.setShortcut(QtGui
.QKeySequence
.Copy
)
890 action
= menu
.addAction(icons
.select_all(), N_('Select All'), self
.selectAll
)
891 action
.setShortcut(QtGui
.QKeySequence
.SelectAll
)
892 menu
.exec_(self
.mapToGlobal(event
.pos()))
894 def mousePressEvent(self
, event
):
895 if event
.button() == Qt
.RightButton
:
896 # Intercept right-click to move the cursor to the current position.
897 # setTextCursor() clears the selection so this is only done when
898 # nothing is selected.
899 if not self
.has_selection():
900 cursor
= self
.cursorForPosition(event
.pos())
901 self
.setTextCursor(cursor
)
903 return super(DiffEditor
, self
).mousePressEvent(event
)
905 def setPlainText(self
, text
):
906 """setPlainText(str) while retaining scrollbar positions"""
909 highlight
= mode
not in (model
.mode_none
, model
.mode_untracked
)
910 self
.highlighter
.set_enabled(highlight
)
912 scrollbar
= self
.verticalScrollBar()
914 scrollvalue
= get(scrollbar
)
921 DiffTextEdit
.setPlainText(self
, text
)
923 if scrollbar
and scrollvalue
is not None:
924 scrollbar
.setValue(scrollvalue
)
926 def selected_lines(self
):
927 cursor
= self
.textCursor()
928 selection_start
= cursor
.selectionStart()
929 selection_end
= max(selection_start
, cursor
.selectionEnd() - 1)
936 for line_idx
, line
in enumerate(get(self
).splitlines()):
937 line_end
= line_start
+ len(line
)
938 if line_start
<= selection_start
<= line_end
:
939 first_line_idx
= line_idx
940 if line_start
<= selection_end
<= line_end
:
941 last_line_idx
= line_idx
943 line_start
= line_end
+ 1
945 if first_line_idx
== -1:
946 first_line_idx
= line_idx
948 if last_line_idx
== -1:
949 last_line_idx
= line_idx
951 return first_line_idx
, last_line_idx
953 def apply_selection(self
):
955 s
= self
.selection_model
.single_selection()
956 if model
.stageable() and (s
.modified
or s
.untracked
):
957 self
.process_diff_selection()
958 elif model
.unstageable():
959 self
.process_diff_selection(reverse
=True)
961 def revert_selection(self
):
962 """Destructively revert selected lines or hunk from a worktree file."""
964 if self
.has_selection():
965 title
= N_('Revert Selected Lines?')
966 ok_text
= N_('Revert Selected Lines')
968 title
= N_('Revert Diff Hunk?')
969 ok_text
= N_('Revert Diff Hunk')
971 if not Interaction
.confirm(
974 'This operation drops uncommitted changes.\n'
975 'These changes cannot be recovered.'
977 N_('Revert the uncommitted changes?'),
983 self
.process_diff_selection(reverse
=True, apply_to_worktree
=True)
985 def process_diff_selection(self
, reverse
=False, apply_to_worktree
=False):
986 """Implement un/staging of the selected line(s) or hunk."""
987 if self
.selection_model
.is_empty():
989 context
= self
.context
990 first_line_idx
, last_line_idx
= self
.selected_lines()
992 cmds
.ApplyDiffSelection
,
996 self
.has_selection(),
1001 def _update_line_number(self
):
1002 """Update the selection model when the cursor changes"""
1003 self
.selection_model
.line_number
= self
.numbers
.current_line()
1006 class DiffWidget(QtWidgets
.QWidget
):
1007 def __init__(self
, context
, notifier
, parent
, is_commit
=False):
1008 QtWidgets
.QWidget
.__init
__(self
, parent
)
1010 self
.context
= context
1013 author_font
= QtGui
.QFont(self
.font())
1014 author_font
.setPointSize(int(author_font
.pointSize() * 1.1))
1016 summary_font
= QtGui
.QFont(author_font
)
1017 summary_font
.setWeight(QtGui
.QFont
.Bold
)
1019 policy
= QtWidgets
.QSizePolicy(
1020 QtWidgets
.QSizePolicy
.MinimumExpanding
, QtWidgets
.QSizePolicy
.Minimum
1023 self
.gravatar_label
= gravatar
.GravatarLabel()
1025 self
.author_label
= TextLabel()
1026 self
.author_label
.setTextFormat(Qt
.RichText
)
1027 self
.author_label
.setFont(author_font
)
1028 self
.author_label
.setSizePolicy(policy
)
1029 self
.author_label
.setAlignment(Qt
.AlignBottom
)
1030 self
.author_label
.elide()
1032 self
.date_label
= TextLabel()
1033 self
.date_label
.setTextFormat(Qt
.PlainText
)
1034 self
.date_label
.setSizePolicy(policy
)
1035 self
.date_label
.setAlignment(Qt
.AlignTop
)
1036 self
.date_label
.hide()
1038 self
.summary_label
= TextLabel()
1039 self
.summary_label
.setTextFormat(Qt
.PlainText
)
1040 self
.summary_label
.setFont(summary_font
)
1041 self
.summary_label
.setSizePolicy(policy
)
1042 self
.summary_label
.setAlignment(Qt
.AlignTop
)
1043 self
.summary_label
.elide()
1045 self
.oid_label
= TextLabel()
1046 self
.oid_label
.setTextFormat(Qt
.PlainText
)
1047 self
.oid_label
.setSizePolicy(policy
)
1048 self
.oid_label
.setAlignment(Qt
.AlignTop
)
1049 self
.oid_label
.elide()
1051 self
.diff
= DiffTextEdit(context
, self
, is_commit
=is_commit
, whitespace
=False)
1053 self
.info_layout
= qtutils
.vbox(
1062 self
.logo_layout
= qtutils
.hbox(
1063 defs
.no_margin
, defs
.button_spacing
, self
.gravatar_label
, self
.info_layout
1065 self
.logo_layout
.setContentsMargins(defs
.margin
, 0, defs
.margin
, 0)
1067 self
.main_layout
= qtutils
.vbox(
1068 defs
.no_margin
, defs
.spacing
, self
.logo_layout
, self
.diff
1070 self
.setLayout(self
.main_layout
)
1072 notifier
.add_observer(COMMITS_SELECTED
, self
.commits_selected
)
1073 notifier
.add_observer(FILES_SELECTED
, self
.files_selected
)
1074 self
.set_tabwidth(prefs
.tabwidth(context
))
1076 def set_tabwidth(self
, width
):
1077 self
.diff
.set_tabwidth(width
)
1079 def set_diff_oid(self
, oid
, filename
=None):
1080 context
= self
.context
1081 self
.diff
.save_scrollbar()
1082 self
.diff
.set_loading_message()
1083 task
= DiffInfoTask(context
, oid
, filename
, self
)
1084 self
.context
.runtask
.start(task
, result
=self
.set_diff
)
1086 def commits_selected(self
, commits
):
1087 if len(commits
) != 1:
1090 oid
= self
.oid
= commit
.oid
1091 email
= commit
.email
or ''
1092 summary
= commit
.summary
or ''
1093 author
= commit
.author
or ''
1095 self
.set_details(oid
, author
, email
, '', summary
)
1096 self
.set_diff_oid(oid
)
1098 def set_diff(self
, diff
):
1099 self
.diff
.set_diff(diff
)
1101 def set_details(self
, oid
, author
, email
, date
, summary
):
1102 template_args
= {'author': author
, 'email': email
, 'summary': summary
}
1104 """%(author)s <"""
1105 """<a href="mailto:%(email)s">"""
1106 """%(email)s</a>>""" % template_args
1108 author_template
= '%(author)s <%(email)s>' % template_args
1110 self
.date_label
.set_text(date
)
1111 self
.date_label
.setVisible(bool(date
))
1112 self
.oid_label
.set_text(oid
)
1113 self
.author_label
.set_template(author_text
, author_template
)
1114 self
.summary_label
.set_text(summary
)
1115 self
.gravatar_label
.set_email(email
)
1117 def files_selected(self
, filenames
):
1120 self
.set_diff_oid(self
.oid
, filenames
[0])
1123 class TextLabel(QtWidgets
.QLabel
):
1124 def __init__(self
, parent
=None):
1125 QtWidgets
.QLabel
.__init
__(self
, parent
)
1126 self
.setTextInteractionFlags(
1127 Qt
.TextSelectableByMouse | Qt
.LinksAccessibleByMouse
1133 self
._metrics
= QtGui
.QFontMetrics(self
.font())
1134 self
.setOpenExternalLinks(True)
1139 def set_text(self
, text
):
1140 self
.set_template(text
, text
)
1142 def set_template(self
, text
, template
):
1143 self
._display
= text
1145 self
._template
= template
1146 self
.update_text(self
.width())
1147 self
.setText(self
._display
)
1149 def update_text(self
, width
):
1150 self
._display
= self
._text
1153 text
= self
._metrics
.elidedText(self
._template
, Qt
.ElideRight
, width
- 2)
1154 if text
!= self
._template
:
1155 self
._display
= text
1158 def setFont(self
, font
):
1159 self
._metrics
= QtGui
.QFontMetrics(font
)
1160 QtWidgets
.QLabel
.setFont(self
, font
)
1162 def resizeEvent(self
, event
):
1164 self
.update_text(event
.size().width())
1165 with qtutils
.BlockSignals(self
):
1166 self
.setText(self
._display
)
1167 QtWidgets
.QLabel
.resizeEvent(self
, event
)
1170 class DiffInfoTask(qtutils
.Task
):
1171 def __init__(self
, context
, oid
, filename
, parent
):
1172 qtutils
.Task
.__init
__(self
, parent
)
1173 self
.context
= context
1175 self
.filename
= filename
1178 context
= self
.context
1180 return gitcmds
.diff_info(context
, oid
, filename
=self
.filename
)