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 prefs
14 from ..qtutils
import get
15 from .. import actions
18 from .. import diffparse
19 from .. import gitcmds
20 from .. import gravatar
21 from .. import hotkeys
24 from .. import qtutils
25 from .text
import TextDecorator
26 from .text
import VimHintedPlainTextEdit
28 from . import imageview
31 COMMITS_SELECTED
= 'COMMITS_SELECTED'
32 FILES_SELECTED
= 'FILES_SELECTED'
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(r
'(?:@@ -[0-9,]+ \+[0-9,]+ @@)|'
48 r
'(?:@@@ (?:-[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(
67 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(
76 fg
=self
.color_header
, bold
=True)
78 self
.diff_add_fmt
= qtutils
.make_format(
79 fg
=self
.color_text
, bg
=self
.color_add
)
80 self
.diff_remove_fmt
= qtutils
.make_format(
81 fg
=self
.color_text
, bg
=self
.color_remove
)
82 self
.bad_whitespace_fmt
= qtutils
.make_format(bg
=Qt
.red
)
83 self
.setCurrentBlockState(self
.INITIAL_STATE
)
85 def set_enabled(self
, enabled
):
86 self
.enabled
= enabled
88 def highlightBlock(self
, text
):
89 if not self
.enabled
or not text
:
91 # Aliases for quick local access
92 initial_state
= self
.INITIAL_STATE
93 default_state
= self
.DEFAULT_STATE
94 diff_state
= self
.DIFF_STATE
95 diffstat_state
= self
.DIFFSTAT_STATE
96 diff_file_header_state
= self
.DIFF_FILE_HEADER_STATE
97 submodule_state
= self
.SUBMODULE_STATE
98 end_state
= self
.END_STATE
100 diff_file_header_start_rgx
= self
.DIFF_FILE_HEADER_START_RGX
101 diff_hunk_header_rgx
= self
.DIFF_HUNK_HEADER_RGX
102 bad_whitespace_rgx
= self
.BAD_WHITESPACE_RGX
104 diff_header_fmt
= self
.diff_header_fmt
105 bold_diff_header_fmt
= self
.bold_diff_header_fmt
106 diff_add_fmt
= self
.diff_add_fmt
107 diff_remove_fmt
= self
.diff_remove_fmt
108 bad_whitespace_fmt
= self
.bad_whitespace_fmt
110 state
= self
.previousBlockState()
111 if state
== initial_state
:
112 if text
.startswith('Submodule '):
113 state
= submodule_state
114 elif text
.startswith('diff --git '):
115 state
= diffstat_state
117 state
= default_state
119 state
= diffstat_state
121 if state
== diffstat_state
:
122 if diff_file_header_start_rgx
.match(text
):
123 state
= diff_file_header_state
124 self
.setFormat(0, len(text
), diff_header_fmt
)
125 elif diff_hunk_header_rgx
.match(text
):
127 self
.setFormat(0, len(text
), bold_diff_header_fmt
)
130 self
.setFormat(0, i
, bold_diff_header_fmt
)
131 self
.setFormat(i
, len(text
) - i
, diff_header_fmt
)
133 self
.setFormat(0, len(text
), diff_header_fmt
)
134 elif state
== diff_file_header_state
:
135 if diff_hunk_header_rgx
.match(text
):
137 self
.setFormat(0, len(text
), bold_diff_header_fmt
)
139 self
.setFormat(0, len(text
), diff_header_fmt
)
140 elif state
== diff_state
:
141 if diff_file_header_start_rgx
.match(text
):
142 state
= diff_file_header_state
143 self
.setFormat(0, len(text
), diff_header_fmt
)
144 elif diff_hunk_header_rgx
.match(text
):
145 self
.setFormat(0, len(text
), bold_diff_header_fmt
)
146 elif text
.startswith('-'):
150 self
.setFormat(0, len(text
), diff_remove_fmt
)
151 elif text
.startswith('+'):
152 self
.setFormat(0, len(text
), diff_add_fmt
)
154 m
= bad_whitespace_rgx
.search(text
)
157 self
.setFormat(i
, len(text
) - i
, bad_whitespace_fmt
)
159 self
.setCurrentBlockState(state
)
162 class DiffTextEdit(VimHintedPlainTextEdit
):
164 def __init__(self
, context
, parent
,
165 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(),
170 is_commit
=is_commit
, whitespace
=whitespace
)
172 self
.numbers
= DiffLineNumbers(context
, self
)
176 self
.scrollvalue
= None
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
):
228 def __init__(self
, context
, parent
):
229 TextDecorator
.__init
__(self
, parent
)
230 self
.highlight_line
= -1
232 self
.parser
= diffparse
.DiffLines()
233 self
.formatter
= diffparse
.FormatDigits()
235 self
.setFont(qtutils
.diff_font(context
))
236 self
._char
_width
= self
.fontMetrics().width('0')
238 QPalette
= QtGui
.QPalette
239 self
._palette
= palette
= self
.palette()
240 self
._base
= palette
.color(QtGui
.QPalette
.Base
)
241 self
._highlight
= palette
.color(QPalette
.Highlight
)
242 self
._highlight
_text
= palette
.color(QPalette
.HighlightedText
)
243 self
._window
= palette
.color(QPalette
.Window
)
244 self
._disabled
= palette
.color(QPalette
.Disabled
, QPalette
.Text
)
246 def set_diff(self
, diff
):
248 lines
= parser
.parse(diff
)
251 self
.formatter
.set_digits(self
.parser
.digits())
255 def set_lines(self
, lines
):
258 def width_hint(self
):
259 if not self
.isVisible():
265 extra
= 3 # one space in-between, one space after
268 extra
= 2 # one space in-between, one space after
271 digits
= parser
.digits() * columns
275 return defs
.margin
+ (self
._char
_width
* (digits
+ extra
))
277 def set_highlighted(self
, line_number
):
278 """Set the line to highlight"""
279 self
.highlight_line
= line_number
281 def current_line(self
):
282 if self
.lines
and self
.highlight_line
>= 0:
283 # Find the next valid line
284 for line
in self
.lines
[self
.highlight_line
:]:
285 # take the "new" line number: last value in tuple
286 line_number
= line
[-1]
290 # Find the previous valid line
291 for line
in self
.lines
[self
.highlight_line
-1::-1]:
292 # take the "new" line number: last value in tuple
293 line_number
= line
[-1]
298 def paintEvent(self
, event
):
299 """Paint the line number"""
303 painter
= QtGui
.QPainter(self
)
304 painter
.fillRect(event
.rect(), self
._base
)
307 content_offset
= editor
.contentOffset()
308 block
= editor
.firstVisibleBlock()
310 event_rect_bottom
= event
.rect().bottom()
312 highlight
= self
._highlight
313 highlight
.setAlphaF(0.3)
314 highlight_text
= self
._highlight
_text
315 disabled
= self
._disabled
319 num_lines
= len(self
.lines
)
320 painter
.setPen(disabled
)
323 while block
.isValid():
324 block_number
= block
.blockNumber()
325 if block_number
>= num_lines
:
327 block_geom
= editor
.blockBoundingGeometry(block
)
328 block_top
= block_geom
.translated(content_offset
).top()
329 if not block
.isVisible() or block_top
>= event_rect_bottom
:
332 rect
= block_geom
.translated(content_offset
).toRect()
333 if block_number
== self
.highlight_line
:
334 painter
.fillRect(rect
.x(), rect
.y(),
335 width
, rect
.height(), highlight
)
336 painter
.setPen(highlight_text
)
338 painter
.setPen(disabled
)
340 line
= lines
[block_number
]
343 text
= fmt
.value(a
, b
)
345 old
, base
, new
= line
346 text
= fmt
.merge_value(old
, base
, new
)
348 painter
.drawText(rect
.x(), rect
.y(),
349 self
.width() - (defs
.margin
* 2), rect
.height(),
350 Qt
.AlignRight | Qt
.AlignVCenter
, text
)
352 block
= block
.next() # pylint: disable=next-method-called
355 class Viewer(QtWidgets
.QFrame
):
356 """Text and image diff viewers"""
358 images_changed
= Signal(object)
359 type_changed
= Signal(object)
361 def __init__(self
, context
, parent
=None):
362 super(Viewer
, self
).__init
__(parent
)
364 self
.context
= context
365 self
.model
= model
= context
.model
368 self
.options
= options
= Options(self
)
369 self
.text
= DiffEditor(context
, options
, self
)
370 self
.image
= imageview
.ImageView(parent
=self
)
371 self
.image
.setFocusPolicy(Qt
.NoFocus
)
373 stack
= self
.stack
= QtWidgets
.QStackedWidget(self
)
374 stack
.addWidget(self
.text
)
375 stack
.addWidget(self
.image
)
377 self
.main_layout
= qtutils
.vbox(
378 defs
.no_margin
, defs
.no_spacing
, self
.stack
)
379 self
.setLayout(self
.main_layout
)
382 images_msg
= model
.message_images_changed
383 model
.add_observer(images_msg
, self
.images_changed
.emit
)
384 self
.images_changed
.connect(self
.set_images
, type=Qt
.QueuedConnection
)
386 # Observe the diff type
387 diff_type_msg
= model
.message_diff_type_changed
388 model
.add_observer(diff_type_msg
, self
.type_changed
.emit
)
389 self
.type_changed
.connect(self
.set_diff_type
, type=Qt
.QueuedConnection
)
391 # Observe the image mode combo box
392 options
.image_mode
.currentIndexChanged
.connect(lambda _
: self
.render())
393 options
.zoom_mode
.currentIndexChanged
.connect(lambda _
: self
.render())
395 self
.setFocusProxy(self
.text
)
397 def export_state(self
, state
):
398 state
['show_diff_line_numbers'] = self
.text
.show_line_numbers()
399 state
['image_diff_mode'] = self
.options
.image_mode
.currentIndex()
400 state
['image_zoom_mode'] = self
.options
.zoom_mode
.currentIndex()
403 def apply_state(self
, state
):
404 diff_numbers
= bool(state
.get('show_diff_line_numbers', False))
405 self
.text
.enable_line_numbers(diff_numbers
)
407 image_mode
= utils
.asint(state
.get('image_diff_mode', 0))
408 self
.options
.image_mode
.set_index(image_mode
)
410 zoom_mode
= utils
.asint(state
.get('image_zoom_mode', 0))
411 self
.options
.zoom_mode
.set_index(zoom_mode
)
414 def set_diff_type(self
, diff_type
):
415 """Manage the image and text diff views when selection changes"""
416 self
.options
.set_diff_type(diff_type
)
417 if diff_type
== 'image':
418 self
.stack
.setCurrentWidget(self
.image
)
421 self
.stack
.setCurrentWidget(self
.text
)
423 def update_options(self
):
424 self
.text
.update_options()
427 self
.image
.pixmap
= QtGui
.QPixmap()
431 for (image
, unlink
) in self
.images
:
432 if unlink
and core
.exists(image
):
436 def set_images(self
, images
):
443 # In order to comp, we first have to load all the images
444 all_pixmaps
= [QtGui
.QPixmap(image
[0]) for image
in images
]
445 pixmaps
= [pixmap
for pixmap
in all_pixmaps
if not pixmap
.isNull()]
450 self
.pixmaps
= pixmaps
458 mode
= self
.options
.image_mode
.currentIndex()
459 if mode
== self
.options
.SIDE_BY_SIDE
:
460 image
= self
.render_side_by_side()
461 elif mode
== self
.options
.DIFF
:
462 image
= self
.render_diff()
463 elif mode
== self
.options
.XOR
:
464 image
= self
.render_xor()
465 elif mode
== self
.options
.PIXEL_XOR
:
466 image
= self
.render_pixel_xor()
468 image
= self
.render_side_by_side()
470 image
= QtGui
.QPixmap()
471 self
.image
.pixmap
= image
474 zoom_mode
= self
.options
.zoom_mode
.currentIndex()
475 zoom_factor
= self
.options
.zoom_factors
[zoom_mode
][1]
476 if zoom_factor
> 0.0:
477 self
.image
.resetTransform()
478 self
.image
.scale(zoom_factor
, zoom_factor
)
479 poly
= self
.image
.mapToScene(self
.image
.viewport().rect())
480 self
.image
.last_scene_roi
= poly
.boundingRect()
482 def render_side_by_side(self
):
483 # Side-by-side lineup comp
484 pixmaps
= self
.pixmaps
485 width
= sum([pixmap
.width() for pixmap
in pixmaps
])
486 height
= max([pixmap
.height() for pixmap
in pixmaps
])
487 image
= create_image(width
, height
)
490 painter
= create_painter(image
)
492 for pixmap
in pixmaps
:
493 painter
.drawPixmap(x
, 0, pixmap
)
499 def render_comp(self
, comp_mode
):
500 # Get the max size to use as the render canvas
501 pixmaps
= self
.pixmaps
502 if len(pixmaps
) == 1:
505 width
= max([pixmap
.width() for pixmap
in pixmaps
])
506 height
= max([pixmap
.height() for pixmap
in pixmaps
])
507 image
= create_image(width
, height
)
509 painter
= create_painter(image
)
510 for pixmap
in (pixmaps
[0], pixmaps
[-1]):
511 x
= (width
- pixmap
.width()) // 2
512 y
= (height
- pixmap
.height()) // 2
513 painter
.drawPixmap(x
, y
, pixmap
)
514 painter
.setCompositionMode(comp_mode
)
519 def render_diff(self
):
520 comp_mode
= QtGui
.QPainter
.CompositionMode_Difference
521 return self
.render_comp(comp_mode
)
523 def render_xor(self
):
524 comp_mode
= QtGui
.QPainter
.CompositionMode_Xor
525 return self
.render_comp(comp_mode
)
527 def render_pixel_xor(self
):
528 comp_mode
= QtGui
.QPainter
.RasterOp_SourceXorDestination
529 return self
.render_comp(comp_mode
)
532 def create_image(width
, height
):
533 size
= QtCore
.QSize(width
, height
)
534 image
= QtGui
.QImage(size
, QtGui
.QImage
.Format_ARGB32_Premultiplied
)
535 image
.fill(Qt
.transparent
)
539 def create_painter(image
):
540 painter
= QtGui
.QPainter(image
)
541 painter
.fillRect(image
.rect(), Qt
.transparent
)
545 class Options(QtWidgets
.QWidget
):
546 """Provide the options widget used by the editor
548 Actions are registered on the parent widget.
552 # mode combobox indexes
558 def __init__(self
, parent
):
559 super(Options
, self
).__init
__(parent
)
561 self
.ignore_space_at_eol
= self
.add_option(
562 N_('Ignore changes in whitespace at EOL'))
564 self
.ignore_space_change
= self
.add_option(
565 N_('Ignore changes in amount of whitespace'))
567 self
.ignore_all_space
= self
.add_option(
568 N_('Ignore all whitespace'))
570 self
.function_context
= self
.add_option(
571 N_('Show whole surrounding functions of changes'))
573 self
.show_line_numbers
= self
.add_option(
574 N_('Show line numbers'))
576 self
.options
= options
= qtutils
.create_action_button(
577 tooltip
=N_('Diff Options'), icon
=icons
.configure())
579 self
.image_mode
= qtutils
.combo([
586 self
.zoom_factors
= (
587 (N_('Zoom to Fit'), 0.0),
595 zoom_modes
= [factor
[0] for factor
in self
.zoom_factors
]
596 self
.zoom_mode
= qtutils
.combo(zoom_modes
, parent
=self
)
598 self
.menu
= menu
= qtutils
.create_menu(N_('Diff Options'), options
)
599 options
.setMenu(menu
)
601 menu
.addAction(self
.ignore_space_at_eol
)
602 menu
.addAction(self
.ignore_space_change
)
603 menu
.addAction(self
.ignore_all_space
)
604 menu
.addAction(self
.show_line_numbers
)
605 menu
.addAction(self
.function_context
)
607 layout
= qtutils
.hbox(
608 defs
.no_margin
, defs
.no_spacing
,
609 self
.image_mode
, defs
.button_spacing
, self
.zoom_mode
, options
)
610 self
.setLayout(layout
)
612 self
.image_mode
.setFocusPolicy(Qt
.NoFocus
)
613 self
.zoom_mode
.setFocusPolicy(Qt
.NoFocus
)
614 self
.options
.setFocusPolicy(Qt
.NoFocus
)
615 self
.setFocusPolicy(Qt
.NoFocus
)
617 def set_diff_type(self
, diff_type
):
618 is_text
= diff_type
== 'text'
619 is_image
= diff_type
== 'image'
620 self
.options
.setVisible(is_text
)
621 self
.image_mode
.setVisible(is_image
)
622 self
.zoom_mode
.setVisible(is_image
)
624 def add_option(self
, title
):
625 action
= qtutils
.add_action(self
, title
, self
.update_options
)
626 action
.setCheckable(True)
629 def update_options(self
):
630 space_at_eol
= get(self
.ignore_space_at_eol
)
631 space_change
= get(self
.ignore_space_change
)
632 all_space
= get(self
.ignore_all_space
)
633 function_context
= get(self
.function_context
)
634 gitcmds
.update_diff_overrides(
635 space_at_eol
, space_change
, all_space
, function_context
)
636 self
.widget
.update_options()
639 class DiffEditor(DiffTextEdit
):
643 options_changed
= Signal()
645 diff_text_updated
= Signal(object)
647 def __init__(self
, context
, options
, parent
):
648 DiffTextEdit
.__init
__(self
, context
, parent
, numbers
=True)
649 self
.context
= context
650 self
.model
= model
= context
.model
651 self
.selection_model
= selection_model
= context
.selection
653 # "Diff Options" tool menu
654 self
.options
= options
655 self
.action_apply_selection
= qtutils
.add_action(
656 self
, 'Apply', self
.apply_selection
, hotkeys
.STAGE_DIFF
)
658 self
.action_revert_selection
= qtutils
.add_action(
659 self
, 'Revert', self
.revert_selection
, hotkeys
.REVERT
)
660 self
.action_revert_selection
.setIcon(icons
.undo())
662 self
.launch_editor
= actions
.launch_editor_at_line(
663 context
, self
, *hotkeys
.ACCEPT
)
664 self
.launch_difftool
= actions
.launch_difftool(context
, self
)
665 self
.stage_or_unstage
= actions
.stage_or_unstage(context
, self
)
667 # Emit up/down signals so that they can be routed by the main widget
668 self
.move_up
= actions
.move_up(self
)
669 self
.move_down
= actions
.move_down(self
)
671 diff_text_updated
= model
.message_diff_text_updated
672 model
.add_observer(diff_text_updated
, self
.diff_text_updated
.emit
)
673 self
.diff_text_updated
.connect(self
.set_diff
, type=Qt
.QueuedConnection
)
675 selection_model
.add_observer(
676 selection_model
.message_selection_changed
, self
.updated
.emit
)
677 self
.updated
.connect(self
.refresh
, type=Qt
.QueuedConnection
)
678 # Update the selection model when the cursor changes
679 self
.cursorPositionChanged
.connect(self
._update
_line
_number
)
683 s
= self
.selection_model
.selection()
685 if s
.modified
and model
.stageable():
686 if s
.modified
[0] in model
.submodules
:
688 elif s
.modified
[0] not in model
.unstaged_deleted
:
690 self
.action_revert_selection
.setEnabled(enabled
)
692 def enable_line_numbers(self
, enabled
):
693 """Enable/disable the diff line number display"""
694 self
.numbers
.setVisible(enabled
)
695 self
.options
.show_line_numbers
.setChecked(enabled
)
697 def show_line_numbers(self
):
698 """Return True if we should show line numbers"""
699 return get(self
.options
.show_line_numbers
)
701 def update_options(self
):
702 self
.numbers
.setVisible(self
.show_line_numbers())
703 self
.options_changed
.emit()
706 def contextMenuEvent(self
, event
):
707 """Create the context menu for the diff display."""
708 menu
= qtutils
.create_menu(N_('Actions'), self
)
709 context
= self
.context
711 s
= self
.selection_model
.selection()
712 filename
= self
.selection_model
.filename()
714 if model
.stageable() or model
.unstageable():
715 if model
.stageable():
716 self
.stage_or_unstage
.setText(N_('Stage'))
718 self
.stage_or_unstage
.setText(N_('Unstage'))
719 menu
.addAction(self
.stage_or_unstage
)
721 if s
.modified
and model
.stageable():
723 if item
in model
.submodules
:
724 path
= core
.abspath(item
)
725 action
= menu
.addAction(
726 icons
.add(), cmds
.Stage
.name(),
727 cmds
.run(cmds
.Stage
, context
, s
.modified
))
728 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
729 menu
.addAction(icons
.cola(), N_('Launch git-cola'),
730 cmds
.run(cmds
.OpenRepo
, context
, path
))
731 elif item
not in model
.unstaged_deleted
:
732 if self
.has_selection():
733 apply_text
= N_('Stage Selected Lines')
734 revert_text
= N_('Revert Selected Lines...')
736 apply_text
= N_('Stage Diff Hunk')
737 revert_text
= N_('Revert Diff Hunk...')
739 self
.action_apply_selection
.setText(apply_text
)
740 self
.action_apply_selection
.setIcon(icons
.add())
742 self
.action_revert_selection
.setText(revert_text
)
744 menu
.addAction(self
.action_apply_selection
)
745 menu
.addAction(self
.action_revert_selection
)
747 if s
.staged
and model
.unstageable():
749 if item
in model
.submodules
:
750 path
= core
.abspath(item
)
751 action
= menu
.addAction(
752 icons
.remove(), cmds
.Unstage
.name(),
753 cmds
.run(cmds
.Unstage
, context
, s
.staged
))
754 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
755 menu
.addAction(icons
.cola(), N_('Launch git-cola'),
756 cmds
.run(cmds
.OpenRepo
, context
, path
))
757 elif item
not in model
.staged_deleted
:
758 if self
.has_selection():
759 apply_text
= N_('Unstage Selected Lines')
761 apply_text
= N_('Unstage Diff Hunk')
763 self
.action_apply_selection
.setText(apply_text
)
764 self
.action_apply_selection
.setIcon(icons
.remove())
765 menu
.addAction(self
.action_apply_selection
)
767 if model
.stageable() or model
.unstageable():
768 # Do not show the "edit" action when the file does not exist.
769 # Untracked files exist by definition.
770 if filename
and core
.exists(filename
):
772 menu
.addAction(self
.launch_editor
)
774 # Removed files can still be diffed.
775 menu
.addAction(self
.launch_difftool
)
777 # Add the Previous/Next File actions, which improves discoverability
778 # of their associated shortcuts
780 menu
.addAction(self
.move_up
)
781 menu
.addAction(self
.move_down
)
784 action
= menu
.addAction(icons
.copy(), N_('Copy'), self
.copy
)
785 action
.setShortcut(QtGui
.QKeySequence
.Copy
)
787 action
= menu
.addAction(icons
.select_all(), N_('Select All'),
789 action
.setShortcut(QtGui
.QKeySequence
.SelectAll
)
790 menu
.exec_(self
.mapToGlobal(event
.pos()))
792 def mousePressEvent(self
, event
):
793 if event
.button() == Qt
.RightButton
:
794 # Intercept right-click to move the cursor to the current position.
795 # setTextCursor() clears the selection so this is only done when
796 # nothing is selected.
797 if not self
.has_selection():
798 cursor
= self
.cursorForPosition(event
.pos())
799 self
.setTextCursor(cursor
)
801 return super(DiffEditor
, self
).mousePressEvent(event
)
803 def setPlainText(self
, text
):
804 """setPlainText(str) while retaining scrollbar positions"""
807 highlight
= mode
not in (model
.mode_none
, model
.mode_untracked
)
808 self
.highlighter
.set_enabled(highlight
)
810 scrollbar
= self
.verticalScrollBar()
812 scrollvalue
= get(scrollbar
)
819 DiffTextEdit
.setPlainText(self
, text
)
821 if scrollbar
and scrollvalue
is not None:
822 scrollbar
.setValue(scrollvalue
)
824 def selected_lines(self
):
825 cursor
= self
.textCursor()
826 selection_start
= cursor
.selectionStart()
827 selection_end
= max(selection_start
, cursor
.selectionEnd() - 1)
834 for line_idx
, line
in enumerate(get(self
).splitlines()):
835 line_end
= line_start
+ len(line
)
836 if line_start
<= selection_start
<= line_end
:
837 first_line_idx
= line_idx
838 if line_start
<= selection_end
<= line_end
:
839 last_line_idx
= line_idx
841 line_start
= line_end
+ 1
843 if first_line_idx
== -1:
844 first_line_idx
= line_idx
846 if last_line_idx
== -1:
847 last_line_idx
= line_idx
849 return first_line_idx
, last_line_idx
851 def apply_selection(self
):
853 s
= self
.selection_model
.single_selection()
854 if model
.stageable() and s
.modified
:
855 self
.process_diff_selection()
856 elif model
.unstageable():
857 self
.process_diff_selection(reverse
=True)
859 def revert_selection(self
):
860 """Destructively revert selected lines or hunk from a worktree file."""
862 if self
.has_selection():
863 title
= N_('Revert Selected Lines?')
864 ok_text
= N_('Revert Selected Lines')
866 title
= N_('Revert Diff Hunk?')
867 ok_text
= N_('Revert Diff Hunk')
869 if not Interaction
.confirm(
871 N_('This operation drops uncommitted changes.\n'
872 'These changes cannot be recovered.'),
873 N_('Revert the uncommitted changes?'),
874 ok_text
, default
=True, icon
=icons
.undo()):
876 self
.process_diff_selection(reverse
=True, apply_to_worktree
=True)
878 def process_diff_selection(self
, reverse
=False, apply_to_worktree
=False):
879 """Implement un/staging of the selected line(s) or hunk."""
880 if self
.selection_model
.is_empty():
882 context
= self
.context
883 first_line_idx
, last_line_idx
= self
.selected_lines()
884 cmds
.do(cmds
.ApplyDiffSelection
, context
,
885 first_line_idx
, last_line_idx
,
886 self
.has_selection(), reverse
, apply_to_worktree
)
888 def _update_line_number(self
):
889 """Update the selection model when the cursor changes"""
890 self
.selection_model
.line_number
= self
.numbers
.current_line()
893 class DiffWidget(QtWidgets
.QWidget
):
895 def __init__(self
, context
, notifier
, parent
, is_commit
=False):
896 QtWidgets
.QWidget
.__init
__(self
, parent
)
898 self
.context
= context
901 author_font
= QtGui
.QFont(self
.font())
902 author_font
.setPointSize(int(author_font
.pointSize() * 1.1))
904 summary_font
= QtGui
.QFont(author_font
)
905 summary_font
.setWeight(QtGui
.QFont
.Bold
)
907 policy
= QtWidgets
.QSizePolicy(QtWidgets
.QSizePolicy
.MinimumExpanding
,
908 QtWidgets
.QSizePolicy
.Minimum
)
910 self
.gravatar_label
= gravatar
.GravatarLabel()
912 self
.author_label
= TextLabel()
913 self
.author_label
.setTextFormat(Qt
.RichText
)
914 self
.author_label
.setFont(author_font
)
915 self
.author_label
.setSizePolicy(policy
)
916 self
.author_label
.setAlignment(Qt
.AlignBottom
)
917 self
.author_label
.elide()
919 self
.date_label
= TextLabel()
920 self
.date_label
.setTextFormat(Qt
.PlainText
)
921 self
.date_label
.setSizePolicy(policy
)
922 self
.date_label
.setAlignment(Qt
.AlignTop
)
923 self
.date_label
.hide()
925 self
.summary_label
= TextLabel()
926 self
.summary_label
.setTextFormat(Qt
.PlainText
)
927 self
.summary_label
.setFont(summary_font
)
928 self
.summary_label
.setSizePolicy(policy
)
929 self
.summary_label
.setAlignment(Qt
.AlignTop
)
930 self
.summary_label
.elide()
932 self
.oid_label
= TextLabel()
933 self
.oid_label
.setTextFormat(Qt
.PlainText
)
934 self
.oid_label
.setSizePolicy(policy
)
935 self
.oid_label
.setAlignment(Qt
.AlignTop
)
936 self
.oid_label
.elide()
938 self
.diff
= DiffTextEdit(
939 context
, self
, is_commit
=is_commit
, whitespace
=False)
941 self
.info_layout
= qtutils
.vbox(defs
.no_margin
, defs
.no_spacing
,
942 self
.author_label
, self
.date_label
,
943 self
.summary_label
, self
.oid_label
)
945 self
.logo_layout
= qtutils
.hbox(defs
.no_margin
, defs
.button_spacing
,
946 self
.gravatar_label
, self
.info_layout
)
947 self
.logo_layout
.setContentsMargins(defs
.margin
, 0, defs
.margin
, 0)
949 self
.main_layout
= qtutils
.vbox(defs
.no_margin
, defs
.spacing
,
950 self
.logo_layout
, self
.diff
)
951 self
.setLayout(self
.main_layout
)
953 notifier
.add_observer(COMMITS_SELECTED
, self
.commits_selected
)
954 notifier
.add_observer(FILES_SELECTED
, self
.files_selected
)
955 self
.set_tabwidth(prefs
.tabwidth(context
))
957 def set_tabwidth(self
, width
):
958 self
.diff
.set_tabwidth(width
)
960 def set_diff_oid(self
, oid
, filename
=None):
961 context
= self
.context
962 self
.diff
.save_scrollbar()
963 self
.diff
.set_loading_message()
964 task
= DiffInfoTask(context
, oid
, filename
, self
)
965 self
.context
.runtask
.start(task
, result
=self
.set_diff
)
967 def commits_selected(self
, commits
):
968 if len(commits
) != 1:
971 oid
= self
.oid
= commit
.oid
972 email
= commit
.email
or ''
973 summary
= commit
.summary
or ''
974 author
= commit
.author
or ''
976 self
.set_details(oid
, author
, email
, '', summary
)
977 self
.set_diff_oid(oid
)
979 def set_diff(self
, diff
):
980 self
.diff
.set_diff(diff
)
982 def set_details(self
, oid
, author
, email
, date
, summary
):
988 author_text
= ("""%(author)s <"""
989 """<a href="mailto:%(email)s">"""
990 """%(email)s</a>>"""
992 author_template
= '%(author)s <%(email)s>' % template_args
994 self
.date_label
.set_text(date
)
995 self
.date_label
.setVisible(bool(date
))
996 self
.oid_label
.set_text(oid
)
997 self
.author_label
.set_template(author_text
, author_template
)
998 self
.summary_label
.set_text(summary
)
999 self
.gravatar_label
.set_email(email
)
1001 def files_selected(self
, filenames
):
1004 self
.set_diff_oid(self
.oid
, filenames
[0])
1007 class TextLabel(QtWidgets
.QLabel
):
1009 def __init__(self
, parent
=None):
1010 QtWidgets
.QLabel
.__init
__(self
, parent
)
1011 self
.setTextInteractionFlags(Qt
.TextSelectableByMouse |
1012 Qt
.LinksAccessibleByMouse
)
1017 self
._metrics
= QtGui
.QFontMetrics(self
.font())
1018 self
.setOpenExternalLinks(True)
1023 def set_text(self
, text
):
1024 self
.set_template(text
, text
)
1026 def set_template(self
, text
, template
):
1027 self
._display
= text
1029 self
._template
= template
1030 self
.update_text(self
.width())
1031 self
.setText(self
._display
)
1033 def update_text(self
, width
):
1034 self
._display
= self
._text
1037 text
= self
._metrics
.elidedText(self
._template
,
1038 Qt
.ElideRight
, width
-2)
1039 if text
!= self
._template
:
1040 self
._display
= text
1043 def setFont(self
, font
):
1044 self
._metrics
= QtGui
.QFontMetrics(font
)
1045 QtWidgets
.QLabel
.setFont(self
, font
)
1047 def resizeEvent(self
, event
):
1049 self
.update_text(event
.size().width())
1050 block
= self
.blockSignals(True)
1051 self
.setText(self
._display
)
1052 self
.blockSignals(block
)
1053 QtWidgets
.QLabel
.resizeEvent(self
, event
)
1056 class DiffInfoTask(qtutils
.Task
):
1058 def __init__(self
, context
, oid
, filename
, parent
):
1059 qtutils
.Task
.__init
__(self
, parent
)
1060 self
.context
= context
1062 self
.filename
= filename
1065 context
= self
.context
1067 return gitcmds
.diff_info(context
, oid
, filename
=self
.filename
)