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 ..qtutils
import get
14 from .. import actions
17 from .. import diffparse
18 from .. import gitcmds
19 from .. import gravatar
20 from .. import hotkeys
23 from .. import qtutils
24 from .text
import TextDecorator
25 from .text
import VimHintedPlainTextEdit
27 from . import imageview
30 COMMITS_SELECTED
= 'COMMITS_SELECTED'
31 FILES_SELECTED
= 'FILES_SELECTED'
34 class DiffSyntaxHighlighter(QtGui
.QSyntaxHighlighter
):
35 """Implements the diff syntax highlighting"""
40 DIFF_FILE_HEADER_STATE
= 2
44 DIFF_FILE_HEADER_START_RGX
= re
.compile(r
'diff --git a/.* b/.*')
45 DIFF_HUNK_HEADER_RGX
= re
.compile(r
'(?:@@ -[0-9,]+ \+[0-9,]+ @@)|'
46 r
'(?:@@@ (?:-[0-9,]+ ){2}\+[0-9,]+ @@@)')
47 BAD_WHITESPACE_RGX
= re
.compile(r
'\s+$')
49 def __init__(self
, context
, doc
, whitespace
=True, is_commit
=False):
50 QtGui
.QSyntaxHighlighter
.__init
__(self
, doc
)
51 self
.whitespace
= whitespace
53 self
.is_commit
= is_commit
55 QPalette
= QtGui
.QPalette
58 disabled
= palette
.color(QPalette
.Disabled
, QPalette
.Text
)
59 header
= qtutils
.rgb_hex(disabled
)
61 self
.color_text
= qtutils
.RGB(cfg
.color('text', '030303'))
62 self
.color_add
= qtutils
.RGB(cfg
.color('add', 'd2ffe4'))
63 self
.color_remove
= qtutils
.RGB(cfg
.color('remove', 'fee0e4'))
64 self
.color_header
= qtutils
.RGB(cfg
.color('header', header
))
66 self
.diff_header_fmt
= qtutils
.make_format(fg
=self
.color_header
)
67 self
.bold_diff_header_fmt
= qtutils
.make_format(
68 fg
=self
.color_header
, bold
=True)
70 self
.diff_add_fmt
= qtutils
.make_format(
71 fg
=self
.color_text
, bg
=self
.color_add
)
72 self
.diff_remove_fmt
= qtutils
.make_format(
73 fg
=self
.color_text
, bg
=self
.color_remove
)
74 self
.bad_whitespace_fmt
= qtutils
.make_format(bg
=Qt
.red
)
75 self
.setCurrentBlockState(self
.INITIAL_STATE
)
77 def set_enabled(self
, enabled
):
78 self
.enabled
= enabled
80 def highlightBlock(self
, text
):
81 if not self
.enabled
or not text
:
84 state
= self
.previousBlockState()
85 if state
== self
.INITIAL_STATE
:
86 if text
.startswith('Submodule '):
87 state
= self
.SUBMODULE_STATE
88 elif text
.startswith('diff --git '):
89 state
= self
.DIFFSTAT_STATE
91 state
= self
.DEFAULT_STATE
93 state
= self
.DIFFSTAT_STATE
95 if state
== self
.DIFFSTAT_STATE
:
96 if self
.DIFF_FILE_HEADER_START_RGX
.match(text
):
97 state
= self
.DIFF_FILE_HEADER_STATE
98 self
.setFormat(0, len(text
), self
.diff_header_fmt
)
99 elif self
.DIFF_HUNK_HEADER_RGX
.match(text
):
100 state
= self
.DIFF_STATE
101 self
.setFormat(0, len(text
), self
.bold_diff_header_fmt
)
104 self
.setFormat(0, i
, self
.bold_diff_header_fmt
)
105 self
.setFormat(i
, len(text
) - i
, self
.diff_header_fmt
)
107 self
.setFormat(0, len(text
), self
.diff_header_fmt
)
108 elif state
== self
.DIFF_FILE_HEADER_STATE
:
109 if self
.DIFF_HUNK_HEADER_RGX
.match(text
):
110 state
= self
.DIFF_STATE
111 self
.setFormat(0, len(text
), self
.bold_diff_header_fmt
)
113 self
.setFormat(0, len(text
), self
.diff_header_fmt
)
114 elif state
== self
.DIFF_STATE
:
115 if self
.DIFF_FILE_HEADER_START_RGX
.match(text
):
116 state
= self
.DIFF_FILE_HEADER_STATE
117 self
.setFormat(0, len(text
), self
.diff_header_fmt
)
118 elif self
.DIFF_HUNK_HEADER_RGX
.match(text
):
119 self
.setFormat(0, len(text
), self
.bold_diff_header_fmt
)
120 elif text
.startswith('-'):
121 self
.setFormat(0, len(text
), self
.diff_remove_fmt
)
122 elif text
.startswith('+'):
123 self
.setFormat(0, len(text
), self
.diff_add_fmt
)
125 m
= self
.BAD_WHITESPACE_RGX
.search(text
)
128 self
.setFormat(i
, len(text
) - i
,
129 self
.bad_whitespace_fmt
)
131 self
.setCurrentBlockState(state
)
134 class DiffTextEdit(VimHintedPlainTextEdit
):
136 def __init__(self
, context
, parent
,
137 is_commit
=False, whitespace
=True, numbers
=False):
138 VimHintedPlainTextEdit
.__init
__(self
, context
, '', parent
=parent
)
139 # Diff/patch syntax highlighter
140 self
.highlighter
= DiffSyntaxHighlighter(
141 context
, self
.document(),
142 is_commit
=is_commit
, whitespace
=whitespace
)
144 self
.numbers
= DiffLineNumbers(context
, self
)
148 self
.scrollvalue
= None
150 self
.cursorPositionChanged
.connect(self
._cursor
_changed
)
152 def _cursor_changed(self
):
153 """Update the line number display when the cursor changes"""
154 line_number
= max(0, self
.textCursor().blockNumber())
156 self
.numbers
.set_highlighted(line_number
)
158 def resizeEvent(self
, event
):
159 super(DiffTextEdit
, self
).resizeEvent(event
)
161 self
.numbers
.refresh_size()
163 def save_scrollbar(self
):
164 """Save the scrollbar value, but only on the first call"""
165 if self
.scrollvalue
is None:
166 scrollbar
= self
.verticalScrollBar()
168 scrollvalue
= get(scrollbar
)
171 self
.scrollvalue
= scrollvalue
173 def restore_scrollbar(self
):
174 """Restore the scrollbar and clear state"""
175 scrollbar
= self
.verticalScrollBar()
176 scrollvalue
= self
.scrollvalue
177 if scrollbar
and scrollvalue
is not None:
178 scrollbar
.setValue(scrollvalue
)
179 self
.scrollvalue
= None
181 def set_loading_message(self
):
182 self
.hint
.set_value('+++ ' + N_('Loading...'))
185 def set_diff(self
, diff
):
186 """Set the diff text, but save the scrollbar"""
187 diff
= diff
.rstrip('\n') # diffs include two empty newlines
188 self
.save_scrollbar()
190 self
.hint
.set_value('')
192 self
.numbers
.set_diff(diff
)
195 self
.restore_scrollbar()
198 class DiffLineNumbers(TextDecorator
):
200 def __init__(self
, context
, parent
):
201 TextDecorator
.__init
__(self
, parent
)
202 self
.highlight_line
= -1
204 self
.parser
= diffparse
.DiffLines()
205 self
.formatter
= diffparse
.FormatDigits()
207 self
.setFont(qtutils
.diff_font(context
))
208 self
._char
_width
= self
.fontMetrics().width('0')
210 QPalette
= QtGui
.QPalette
211 self
._palette
= palette
= self
.palette()
212 self
._base
= palette
.color(QtGui
.QPalette
.Base
)
213 self
._highlight
= palette
.color(QPalette
.Highlight
)
214 self
._highlight
_text
= palette
.color(QPalette
.HighlightedText
)
215 self
._window
= palette
.color(QPalette
.Window
)
216 self
._disabled
= palette
.color(QPalette
.Disabled
, QPalette
.Text
)
218 def set_diff(self
, diff
):
220 lines
= parser
.parse(diff
)
223 self
.formatter
.set_digits(self
.parser
.digits())
227 def set_lines(self
, lines
):
230 def width_hint(self
):
231 if not self
.isVisible():
237 extra
= 3 # one space in-between, one space after
240 extra
= 2 # one space in-between, one space after
243 digits
= parser
.digits() * columns
247 return defs
.margin
+ (self
._char
_width
* (digits
+ extra
))
249 def set_highlighted(self
, line_number
):
250 """Set the line to highlight"""
251 self
.highlight_line
= line_number
253 def current_line(self
):
254 if self
.lines
and self
.highlight_line
>= 0:
255 # Find the next valid line
256 for line
in self
.lines
[self
.highlight_line
:]:
257 # take the "new" line number: last value in tuple
258 line_number
= line
[-1]
262 # Find the previous valid line
263 for line
in self
.lines
[self
.highlight_line
-1::-1]:
264 # take the "new" line number: last value in tuple
265 line_number
= line
[-1]
270 def paintEvent(self
, event
):
271 """Paint the line number"""
275 painter
= QtGui
.QPainter(self
)
276 painter
.fillRect(event
.rect(), self
._base
)
279 content_offset
= editor
.contentOffset()
280 block
= editor
.firstVisibleBlock()
282 event_rect_bottom
= event
.rect().bottom()
284 highlight
= self
._highlight
285 highlight_text
= self
._highlight
_text
286 disabled
= self
._disabled
290 num_lines
= len(self
.lines
)
291 painter
.setPen(disabled
)
294 while block
.isValid():
295 block_number
= block
.blockNumber()
296 if block_number
>= num_lines
:
298 block_geom
= editor
.blockBoundingGeometry(block
)
299 block_top
= block_geom
.translated(content_offset
).top()
300 if not block
.isVisible() or block_top
>= event_rect_bottom
:
303 rect
= block_geom
.translated(content_offset
).toRect()
304 if block_number
== self
.highlight_line
:
305 painter
.fillRect(rect
.x(), rect
.y(),
306 width
, rect
.height(), highlight
)
307 painter
.setPen(highlight_text
)
309 painter
.setPen(disabled
)
311 line
= lines
[block_number
]
314 text
= fmt
.value(a
, b
)
316 old
, base
, new
= line
317 text
= fmt
.merge_value(old
, base
, new
)
319 painter
.drawText(rect
.x(), rect
.y(),
320 self
.width() - (defs
.margin
* 2), rect
.height(),
321 Qt
.AlignRight | Qt
.AlignVCenter
, text
)
323 block
= block
.next() # pylint: disable=next-method-called
326 class Viewer(QtWidgets
.QWidget
):
327 """Text and image diff viewers"""
329 images_changed
= Signal(object)
330 type_changed
= Signal(object)
332 def __init__(self
, context
, parent
=None):
333 super(Viewer
, self
).__init
__(parent
)
335 self
.context
= context
336 self
.model
= model
= context
.model
339 self
.options
= options
= Options(self
)
340 self
.text
= DiffEditor(context
, options
, self
)
341 self
.image
= imageview
.ImageView(parent
=self
)
342 self
.image
.setFocusPolicy(Qt
.NoFocus
)
344 stack
= self
.stack
= QtWidgets
.QStackedWidget(self
)
345 stack
.addWidget(self
.text
)
346 stack
.addWidget(self
.image
)
348 self
.main_layout
= qtutils
.vbox(
349 defs
.no_margin
, defs
.no_spacing
, self
.stack
)
350 self
.setLayout(self
.main_layout
)
353 images_msg
= model
.message_images_changed
354 model
.add_observer(images_msg
, self
.images_changed
.emit
)
355 self
.images_changed
.connect(self
.set_images
, type=Qt
.QueuedConnection
)
357 # Observe the diff type
358 diff_type_msg
= model
.message_diff_type_changed
359 model
.add_observer(diff_type_msg
, self
.type_changed
.emit
)
360 self
.type_changed
.connect(self
.set_diff_type
, type=Qt
.QueuedConnection
)
362 # Observe the image mode combo box
363 options
.image_mode
.currentIndexChanged
.connect(lambda _
: self
.render())
364 options
.zoom_mode
.currentIndexChanged
.connect(lambda _
: self
.render())
366 self
.setFocusProxy(self
.text
)
368 def export_state(self
, state
):
369 state
['show_diff_line_numbers'] = self
.text
.show_line_numbers()
370 state
['image_diff_mode'] = self
.options
.image_mode
.currentIndex()
371 state
['image_zoom_mode'] = self
.options
.zoom_mode
.currentIndex()
374 def apply_state(self
, state
):
375 diff_numbers
= bool(state
.get('show_diff_line_numbers', False))
376 self
.text
.enable_line_numbers(diff_numbers
)
378 image_mode
= utils
.asint(state
.get('image_diff_mode', 0))
379 self
.options
.image_mode
.set_index(image_mode
)
381 zoom_mode
= utils
.asint(state
.get('image_zoom_mode', 0))
382 self
.options
.zoom_mode
.set_index(zoom_mode
)
385 def set_diff_type(self
, diff_type
):
386 """Manage the image and text diff views when selection changes"""
387 self
.options
.set_diff_type(diff_type
)
388 if diff_type
== 'image':
389 self
.stack
.setCurrentWidget(self
.image
)
392 self
.stack
.setCurrentWidget(self
.text
)
394 def update_options(self
):
395 self
.text
.update_options()
398 self
.image
.pixmap
= QtGui
.QPixmap()
402 for (image
, unlink
) in self
.images
:
403 if unlink
and core
.exists(image
):
407 def set_images(self
, images
):
414 # In order to comp, we first have to load all the images
415 all_pixmaps
= [QtGui
.QPixmap(image
[0]) for image
in images
]
416 pixmaps
= [pixmap
for pixmap
in all_pixmaps
if not pixmap
.isNull()]
421 self
.pixmaps
= pixmaps
429 mode
= self
.options
.image_mode
.currentIndex()
430 if mode
== self
.options
.SIDE_BY_SIDE
:
431 image
= self
.render_side_by_side()
432 elif mode
== self
.options
.DIFF
:
433 image
= self
.render_diff()
434 elif mode
== self
.options
.XOR
:
435 image
= self
.render_xor()
436 elif mode
== self
.options
.PIXEL_XOR
:
437 image
= self
.render_pixel_xor()
439 image
= self
.render_side_by_side()
441 image
= QtGui
.QPixmap()
442 self
.image
.pixmap
= image
445 zoom_mode
= self
.options
.zoom_mode
.currentIndex()
446 zoom_factor
= self
.options
.zoom_factors
[zoom_mode
][1]
447 if zoom_factor
> 0.0:
448 self
.image
.resetTransform()
449 self
.image
.scale(zoom_factor
, zoom_factor
)
450 poly
= self
.image
.mapToScene(self
.image
.viewport().rect())
451 self
.image
.last_scene_roi
= poly
.boundingRect()
453 def render_side_by_side(self
):
454 # Side-by-side lineup comp
455 pixmaps
= self
.pixmaps
456 width
= sum([pixmap
.width() for pixmap
in pixmaps
])
457 height
= max([pixmap
.height() for pixmap
in pixmaps
])
458 image
= create_image(width
, height
)
461 painter
= create_painter(image
)
463 for pixmap
in pixmaps
:
464 painter
.drawPixmap(x
, 0, pixmap
)
470 def render_comp(self
, comp_mode
):
471 # Get the max size to use as the render canvas
472 pixmaps
= self
.pixmaps
473 if len(pixmaps
) == 1:
476 width
= max([pixmap
.width() for pixmap
in pixmaps
])
477 height
= max([pixmap
.height() for pixmap
in pixmaps
])
478 image
= create_image(width
, height
)
480 painter
= create_painter(image
)
481 for pixmap
in (pixmaps
[0], pixmaps
[-1]):
482 x
= (width
- pixmap
.width()) // 2
483 y
= (height
- pixmap
.height()) // 2
484 painter
.drawPixmap(x
, y
, pixmap
)
485 painter
.setCompositionMode(comp_mode
)
490 def render_diff(self
):
491 comp_mode
= QtGui
.QPainter
.CompositionMode_Difference
492 return self
.render_comp(comp_mode
)
494 def render_xor(self
):
495 comp_mode
= QtGui
.QPainter
.CompositionMode_Xor
496 return self
.render_comp(comp_mode
)
498 def render_pixel_xor(self
):
499 comp_mode
= QtGui
.QPainter
.RasterOp_SourceXorDestination
500 return self
.render_comp(comp_mode
)
503 def create_image(width
, height
):
504 size
= QtCore
.QSize(width
, height
)
505 image
= QtGui
.QImage(size
, QtGui
.QImage
.Format_ARGB32_Premultiplied
)
506 image
.fill(Qt
.transparent
)
510 def create_painter(image
):
511 painter
= QtGui
.QPainter(image
)
512 painter
.fillRect(image
.rect(), Qt
.transparent
)
516 class Options(QtWidgets
.QWidget
):
517 """Provide the options widget used by the editor
519 Actions are registered on the parent widget.
523 # mode combobox indexes
529 def __init__(self
, parent
):
530 super(Options
, self
).__init
__(parent
)
532 self
.ignore_space_at_eol
= self
.add_option(
533 N_('Ignore changes in whitespace at EOL'))
535 self
.ignore_space_change
= self
.add_option(
536 N_('Ignore changes in amount of whitespace'))
538 self
.ignore_all_space
= self
.add_option(
539 N_('Ignore all whitespace'))
541 self
.function_context
= self
.add_option(
542 N_('Show whole surrounding functions of changes'))
544 self
.show_line_numbers
= self
.add_option(
545 N_('Show line numbers'))
547 self
.options
= options
= qtutils
.create_action_button(
548 tooltip
=N_('Diff Options'), icon
=icons
.configure())
549 qtutils
.hide_button_menu_indicator(options
)
551 self
.image_mode
= qtutils
.combo([
558 self
.zoom_factors
= (
559 (N_('Zoom to Fit'), 0.0),
567 zoom_modes
= [factor
[0] for factor
in self
.zoom_factors
]
568 self
.zoom_mode
= qtutils
.combo(zoom_modes
, parent
=self
)
570 self
.menu
= menu
= qtutils
.create_menu(N_('Diff Options'), options
)
571 options
.setMenu(menu
)
573 menu
.addAction(self
.ignore_space_at_eol
)
574 menu
.addAction(self
.ignore_space_change
)
575 menu
.addAction(self
.ignore_all_space
)
576 menu
.addAction(self
.show_line_numbers
)
577 menu
.addAction(self
.function_context
)
579 layout
= qtutils
.hbox(
580 defs
.no_margin
, defs
.no_spacing
,
581 self
.image_mode
, defs
.button_spacing
, self
.zoom_mode
, options
)
582 self
.setLayout(layout
)
584 self
.image_mode
.setFocusPolicy(Qt
.NoFocus
)
585 self
.zoom_mode
.setFocusPolicy(Qt
.NoFocus
)
586 self
.options
.setFocusPolicy(Qt
.NoFocus
)
587 self
.setFocusPolicy(Qt
.NoFocus
)
589 def set_diff_type(self
, diff_type
):
590 is_text
= diff_type
== 'text'
591 is_image
= diff_type
== 'image'
592 self
.options
.setVisible(is_text
)
593 self
.image_mode
.setVisible(is_image
)
594 self
.zoom_mode
.setVisible(is_image
)
596 def add_option(self
, title
):
597 action
= qtutils
.add_action(self
, title
, self
.update_options
)
598 action
.setCheckable(True)
601 def update_options(self
):
602 space_at_eol
= get(self
.ignore_space_at_eol
)
603 space_change
= get(self
.ignore_space_change
)
604 all_space
= get(self
.ignore_all_space
)
605 function_context
= get(self
.function_context
)
606 gitcmds
.update_diff_overrides(
607 space_at_eol
, space_change
, all_space
, function_context
)
608 self
.widget
.update_options()
611 class DiffEditor(DiffTextEdit
):
615 options_changed
= Signal()
617 diff_text_updated
= Signal(object)
619 def __init__(self
, context
, options
, parent
):
620 DiffTextEdit
.__init
__(self
, context
, parent
, numbers
=True)
621 self
.context
= context
622 self
.model
= model
= context
.model
623 self
.selection_model
= selection_model
= context
.selection
625 # "Diff Options" tool menu
626 self
.options
= options
627 self
.action_apply_selection
= qtutils
.add_action(
628 self
, 'Apply', self
.apply_selection
, hotkeys
.STAGE_DIFF
)
630 self
.action_revert_selection
= qtutils
.add_action(
631 self
, 'Revert', self
.revert_selection
, hotkeys
.REVERT
)
632 self
.action_revert_selection
.setIcon(icons
.undo())
634 self
.launch_editor
= actions
.launch_editor_at_line(
635 context
, self
, *hotkeys
.ACCEPT
)
636 self
.launch_difftool
= actions
.launch_difftool(context
, self
)
637 self
.stage_or_unstage
= actions
.stage_or_unstage(context
, self
)
639 # Emit up/down signals so that they can be routed by the main widget
640 self
.move_up
= actions
.move_up(self
)
641 self
.move_down
= actions
.move_down(self
)
643 diff_text_updated
= model
.message_diff_text_updated
644 model
.add_observer(diff_text_updated
, self
.diff_text_updated
.emit
)
645 self
.diff_text_updated
.connect(self
.set_diff
, type=Qt
.QueuedConnection
)
647 selection_model
.add_observer(
648 selection_model
.message_selection_changed
, self
.updated
.emit
)
649 self
.updated
.connect(self
.refresh
, type=Qt
.QueuedConnection
)
650 # Update the selection model when the cursor changes
651 self
.cursorPositionChanged
.connect(self
._update
_line
_number
)
655 s
= self
.selection_model
.selection()
657 if s
.modified
and model
.stageable():
658 if s
.modified
[0] in model
.submodules
:
660 elif s
.modified
[0] not in model
.unstaged_deleted
:
662 self
.action_revert_selection
.setEnabled(enabled
)
664 def enable_line_numbers(self
, enabled
):
665 """Enable/disable the diff line number display"""
666 self
.numbers
.setVisible(enabled
)
667 self
.options
.show_line_numbers
.setChecked(enabled
)
669 def show_line_numbers(self
):
670 """Return True if we should show line numbers"""
671 return get(self
.options
.show_line_numbers
)
673 def update_options(self
):
674 self
.numbers
.setVisible(self
.show_line_numbers())
675 self
.options_changed
.emit()
678 def contextMenuEvent(self
, event
):
679 """Create the context menu for the diff display."""
680 menu
= qtutils
.create_menu(N_('Actions'), self
)
681 context
= self
.context
683 s
= self
.selection_model
.selection()
684 filename
= self
.selection_model
.filename()
686 if model
.stageable() or model
.unstageable():
687 if model
.stageable():
688 self
.stage_or_unstage
.setText(N_('Stage'))
690 self
.stage_or_unstage
.setText(N_('Unstage'))
691 menu
.addAction(self
.stage_or_unstage
)
693 if s
.modified
and model
.stageable():
695 if item
in model
.submodules
:
696 path
= core
.abspath(item
)
697 action
= menu
.addAction(
698 icons
.add(), cmds
.Stage
.name(),
699 cmds
.run(cmds
.Stage
, context
, s
.modified
))
700 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
701 menu
.addAction(icons
.cola(), N_('Launch git-cola'),
702 cmds
.run(cmds
.OpenRepo
, context
, path
))
703 elif item
not in model
.unstaged_deleted
:
704 if self
.has_selection():
705 apply_text
= N_('Stage Selected Lines')
706 revert_text
= N_('Revert Selected Lines...')
708 apply_text
= N_('Stage Diff Hunk')
709 revert_text
= N_('Revert Diff Hunk...')
711 self
.action_apply_selection
.setText(apply_text
)
712 self
.action_apply_selection
.setIcon(icons
.add())
714 self
.action_revert_selection
.setText(revert_text
)
716 menu
.addAction(self
.action_apply_selection
)
717 menu
.addAction(self
.action_revert_selection
)
719 if s
.staged
and model
.unstageable():
721 if item
in model
.submodules
:
722 path
= core
.abspath(item
)
723 action
= menu
.addAction(
724 icons
.remove(), cmds
.Unstage
.name(),
725 cmds
.run(cmds
.Unstage
, context
, s
.staged
))
726 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
727 menu
.addAction(icons
.cola(), N_('Launch git-cola'),
728 cmds
.run(cmds
.OpenRepo
, context
, path
))
729 elif item
not in model
.staged_deleted
:
730 if self
.has_selection():
731 apply_text
= N_('Unstage Selected Lines')
733 apply_text
= N_('Unstage Diff Hunk')
735 self
.action_apply_selection
.setText(apply_text
)
736 self
.action_apply_selection
.setIcon(icons
.remove())
737 menu
.addAction(self
.action_apply_selection
)
739 if model
.stageable() or model
.unstageable():
740 # Do not show the "edit" action when the file does not exist.
741 # Untracked files exist by definition.
742 if filename
and core
.exists(filename
):
744 menu
.addAction(self
.launch_editor
)
746 # Removed files can still be diffed.
747 menu
.addAction(self
.launch_difftool
)
749 # Add the Previous/Next File actions, which improves discoverability
750 # of their associated shortcuts
752 menu
.addAction(self
.move_up
)
753 menu
.addAction(self
.move_down
)
756 action
= menu
.addAction(icons
.copy(), N_('Copy'), self
.copy
)
757 action
.setShortcut(QtGui
.QKeySequence
.Copy
)
759 action
= menu
.addAction(icons
.select_all(), N_('Select All'),
761 action
.setShortcut(QtGui
.QKeySequence
.SelectAll
)
762 menu
.exec_(self
.mapToGlobal(event
.pos()))
764 def mousePressEvent(self
, event
):
765 if event
.button() == Qt
.RightButton
:
766 # Intercept right-click to move the cursor to the current position.
767 # setTextCursor() clears the selection so this is only done when
768 # nothing is selected.
769 if not self
.has_selection():
770 cursor
= self
.cursorForPosition(event
.pos())
771 self
.setTextCursor(cursor
)
773 return super(DiffEditor
, self
).mousePressEvent(event
)
775 def setPlainText(self
, text
):
776 """setPlainText(str) while retaining scrollbar positions"""
779 highlight
= (mode
!= model
.mode_none
and
780 mode
!= model
.mode_untracked
)
781 self
.highlighter
.set_enabled(highlight
)
783 scrollbar
= self
.verticalScrollBar()
785 scrollvalue
= get(scrollbar
)
792 DiffTextEdit
.setPlainText(self
, text
)
794 if scrollbar
and scrollvalue
is not None:
795 scrollbar
.setValue(scrollvalue
)
797 def selected_lines(self
):
798 cursor
= self
.textCursor()
799 selection_start
= cursor
.selectionStart()
800 selection_end
= max(selection_start
, cursor
.selectionEnd() - 1)
807 for line_idx
, line
in enumerate(get(self
).splitlines()):
808 line_end
= line_start
+ len(line
)
809 if line_start
<= selection_start
<= line_end
:
810 first_line_idx
= line_idx
811 if line_start
<= selection_end
<= line_end
:
812 last_line_idx
= line_idx
814 line_start
= line_end
+ 1
816 if first_line_idx
== -1:
817 first_line_idx
= line_idx
819 if last_line_idx
== -1:
820 last_line_idx
= line_idx
822 return first_line_idx
, last_line_idx
824 def apply_selection(self
):
826 s
= self
.selection_model
.single_selection()
827 if model
.stageable() and s
.modified
:
828 self
.process_diff_selection()
829 elif model
.unstageable():
830 self
.process_diff_selection(reverse
=True)
832 def revert_selection(self
):
833 """Destructively revert selected lines or hunk from a worktree file."""
835 if self
.has_selection():
836 title
= N_('Revert Selected Lines?')
837 ok_text
= N_('Revert Selected Lines')
839 title
= N_('Revert Diff Hunk?')
840 ok_text
= N_('Revert Diff Hunk')
842 if not Interaction
.confirm(
844 N_('This operation drops uncommitted changes.\n'
845 'These changes cannot be recovered.'),
846 N_('Revert the uncommitted changes?'),
847 ok_text
, default
=True, icon
=icons
.undo()):
849 self
.process_diff_selection(reverse
=True, apply_to_worktree
=True)
851 def process_diff_selection(self
, reverse
=False, apply_to_worktree
=False):
852 """Implement un/staging of the selected line(s) or hunk."""
853 if self
.selection_model
.is_empty():
855 context
= self
.context
856 first_line_idx
, last_line_idx
= self
.selected_lines()
857 cmds
.do(cmds
.ApplyDiffSelection
, context
,
858 first_line_idx
, last_line_idx
,
859 self
.has_selection(), reverse
, apply_to_worktree
)
861 def _update_line_number(self
):
862 """Update the selection model when the cursor changes"""
863 self
.selection_model
.line_number
= self
.numbers
.current_line()
866 class DiffWidget(QtWidgets
.QWidget
):
868 def __init__(self
, context
, notifier
, parent
, is_commit
=False):
869 QtWidgets
.QWidget
.__init
__(self
, parent
)
871 self
.context
= context
874 author_font
= QtGui
.QFont(self
.font())
875 author_font
.setPointSize(int(author_font
.pointSize() * 1.1))
877 summary_font
= QtGui
.QFont(author_font
)
878 summary_font
.setWeight(QtGui
.QFont
.Bold
)
880 policy
= QtWidgets
.QSizePolicy(QtWidgets
.QSizePolicy
.MinimumExpanding
,
881 QtWidgets
.QSizePolicy
.Minimum
)
883 self
.gravatar_label
= gravatar
.GravatarLabel()
885 self
.author_label
= TextLabel()
886 self
.author_label
.setTextFormat(Qt
.RichText
)
887 self
.author_label
.setFont(author_font
)
888 self
.author_label
.setSizePolicy(policy
)
889 self
.author_label
.setAlignment(Qt
.AlignBottom
)
890 self
.author_label
.elide()
892 self
.summary_label
= TextLabel()
893 self
.summary_label
.setTextFormat(Qt
.PlainText
)
894 self
.summary_label
.setFont(summary_font
)
895 self
.summary_label
.setSizePolicy(policy
)
896 self
.summary_label
.setAlignment(Qt
.AlignTop
)
897 self
.summary_label
.elide()
899 self
.oid_label
= TextLabel()
900 self
.oid_label
.setTextFormat(Qt
.PlainText
)
901 self
.oid_label
.setSizePolicy(policy
)
902 self
.oid_label
.setAlignment(Qt
.AlignTop
)
903 self
.oid_label
.elide()
905 self
.diff
= DiffTextEdit(
906 context
, self
, is_commit
=is_commit
, whitespace
=False)
908 self
.info_layout
= qtutils
.vbox(defs
.no_margin
, defs
.no_spacing
,
909 self
.author_label
, self
.summary_label
,
912 self
.logo_layout
= qtutils
.hbox(defs
.no_margin
, defs
.button_spacing
,
913 self
.gravatar_label
, self
.info_layout
)
914 self
.logo_layout
.setContentsMargins(defs
.margin
, 0, defs
.margin
, 0)
916 self
.main_layout
= qtutils
.vbox(defs
.no_margin
, defs
.spacing
,
917 self
.logo_layout
, self
.diff
)
918 self
.setLayout(self
.main_layout
)
920 notifier
.add_observer(COMMITS_SELECTED
, self
.commits_selected
)
921 notifier
.add_observer(FILES_SELECTED
, self
.files_selected
)
923 def set_tabwidth(self
, width
):
924 self
.diff
.set_tabwidth(width
)
926 def set_diff_oid(self
, oid
, filename
=None):
927 context
= self
.context
928 self
.diff
.save_scrollbar()
929 self
.diff
.set_loading_message()
930 task
= DiffInfoTask(context
, oid
, filename
, self
)
931 self
.context
.runtask
.start(task
, result
=self
.diff
.set_diff
)
933 def commits_selected(self
, commits
):
934 if len(commits
) != 1:
937 self
.oid
= commit
.oid
939 email
= commit
.email
or ''
940 summary
= commit
.summary
or ''
941 author
= commit
.author
or ''
949 author_text
= ("""%(author)s <"""
950 """<a href="mailto:%(email)s">"""
951 """%(email)s</a>>"""
954 author_template
= '%(author)s <%(email)s>' % template_args
955 self
.author_label
.set_template(author_text
, author_template
)
956 self
.summary_label
.set_text(summary
)
957 self
.oid_label
.set_text(self
.oid
)
959 self
.set_diff_oid(self
.oid
)
960 self
.gravatar_label
.set_email(email
)
962 def files_selected(self
, filenames
):
965 self
.set_diff_oid(self
.oid
, filenames
[0])
968 class TextLabel(QtWidgets
.QLabel
):
970 def __init__(self
, parent
=None):
971 QtWidgets
.QLabel
.__init
__(self
, parent
)
972 self
.setTextInteractionFlags(Qt
.TextSelectableByMouse |
973 Qt
.LinksAccessibleByMouse
)
978 self
._metrics
= QtGui
.QFontMetrics(self
.font())
979 self
.setOpenExternalLinks(True)
984 def set_text(self
, text
):
985 self
.set_template(text
, text
)
987 def set_template(self
, text
, template
):
990 self
._template
= template
991 self
.update_text(self
.width())
992 self
.setText(self
._display
)
994 def update_text(self
, width
):
995 self
._display
= self
._text
998 text
= self
._metrics
.elidedText(self
._template
,
999 Qt
.ElideRight
, width
-2)
1000 if text
!= self
._template
:
1001 self
._display
= text
1004 def setFont(self
, font
):
1005 self
._metrics
= QtGui
.QFontMetrics(font
)
1006 QtWidgets
.QLabel
.setFont(self
, font
)
1008 def resizeEvent(self
, event
):
1010 self
.update_text(event
.size().width())
1011 block
= self
.blockSignals(True)
1012 self
.setText(self
._display
)
1013 self
.blockSignals(block
)
1014 QtWidgets
.QLabel
.resizeEvent(self
, event
)
1017 class DiffInfoTask(qtutils
.Task
):
1019 def __init__(self
, context
, oid
, filename
, parent
):
1020 qtutils
.Task
.__init
__(self
, parent
)
1021 self
.context
= context
1023 self
.filename
= filename
1026 context
= self
.context
1028 return gitcmds
.diff_info(context
, oid
, filename
=self
.filename
)