widgets: use qtutils.BlockSignals to manage blockSignals(bool)
[git-cola.git] / cola / widgets / diff.py
blob02a072258b76b6d661860a7529328226b122bd5a
1 from __future__ import division, absolute_import, unicode_literals
2 import os
3 import re
5 from qtpy import QtCore
6 from qtpy import QtGui
7 from qtpy import QtWidgets
8 from qtpy.QtCore import Qt
9 from qtpy.QtCore import Signal
11 from ..i18n import N_
12 from ..interaction import Interaction
13 from ..models import main
14 from ..models import prefs
15 from ..qtutils import get
16 from .. import actions
17 from .. import cmds
18 from .. import core
19 from .. import diffparse
20 from .. import gitcmds
21 from .. import gravatar
22 from .. import hotkeys
23 from .. import icons
24 from .. import utils
25 from .. import qtutils
26 from .text import TextDecorator
27 from .text import VimHintedPlainTextEdit
28 from . import defs
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"""
39 INITIAL_STATE = -1
40 DEFAULT_STATE = 0
41 DIFFSTAT_STATE = 1
42 DIFF_FILE_HEADER_STATE = 2
43 DIFF_STATE = 3
44 SUBMODULE_STATE = 4
45 END_STATE = 5
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
56 self.enabled = True
57 self.is_commit = is_commit
59 QPalette = QtGui.QPalette
60 cfg = context.cfg
61 palette = 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:
89 return
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
115 elif self.is_commit:
116 state = default_state
117 else:
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):
125 state = diff_state
126 self.setFormat(0, len(text), bold_diff_header_fmt)
127 elif '|' in text:
128 i = text.index('|')
129 self.setFormat(0, i, bold_diff_header_fmt)
130 self.setFormat(i, len(text) - i, diff_header_fmt)
131 else:
132 self.setFormat(0, len(text), diff_header_fmt)
133 elif state == diff_file_header_state:
134 if diff_hunk_header_rgx.match(text):
135 state = diff_state
136 self.setFormat(0, len(text), bold_diff_header_fmt)
137 else:
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('-'):
146 if text == '-- ':
147 state = end_state
148 else:
149 self.setFormat(0, len(text), diff_remove_fmt)
150 elif text.startswith('+'):
151 self.setFormat(0, len(text), diff_add_fmt)
152 if self.whitespace:
153 m = bad_whitespace_rgx.search(text)
154 if m is not None:
155 i = m.start()
156 self.setFormat(i, len(text) - i, bad_whitespace_fmt)
158 self.setCurrentBlockState(state)
161 # pylint: disable=too-many-ancestors
162 class DiffTextEdit(VimHintedPlainTextEdit):
163 def __init__(
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
171 if numbers:
172 self.numbers = DiffLineNumbers(context, self)
173 self.numbers.hide()
174 else:
175 self.numbers = None
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())
183 if self.numbers:
184 self.numbers.set_highlighted(line_number)
186 def resizeEvent(self, event):
187 super(DiffTextEdit, self).resizeEvent(event)
188 if self.numbers:
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()
195 if scrollbar:
196 scrollvalue = get(scrollbar)
197 else:
198 scrollvalue = None
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...'))
211 self.set_value('')
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('')
219 if self.numbers:
220 self.numbers.set_diff(diff)
221 self.set_value(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
230 self.lines = None
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):
246 parser = self.parser
247 lines = parser.parse(diff)
248 if parser.valid:
249 self.lines = lines
250 self.formatter.set_digits(self.parser.digits())
251 else:
252 self.lines = None
254 def set_lines(self, lines):
255 self.lines = lines
257 def width_hint(self):
258 if not self.isVisible():
259 return 0
260 parser = self.parser
262 if parser.merge:
263 columns = 3
264 extra = 3 # one space in-between, one space after
265 else:
266 columns = 2
267 extra = 2 # one space in-between, one space after
269 if parser.valid:
270 digits = parser.digits() * columns
271 else:
272 digits = 4
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]
286 if line_number > 0:
287 return line_number
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]
293 if line_number > 0:
294 return line_number
295 return None
297 def paintEvent(self, event):
298 """Paint the line number"""
299 if not self.lines:
300 return
302 painter = QtGui.QPainter(self)
303 painter.fillRect(event.rect(), self._base)
305 editor = self.editor
306 content_offset = editor.contentOffset()
307 block = editor.firstVisibleBlock()
308 width = self.width()
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
316 fmt = self.formatter
317 lines = self.lines
318 num_lines = len(self.lines)
319 painter.setPen(disabled)
320 text = ''
322 while block.isValid():
323 block_number = block.blockNumber()
324 if block_number >= num_lines:
325 break
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:
329 break
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)
335 else:
336 painter.setPen(disabled)
338 line = lines[block_number]
339 if len(line) == 2:
340 a, b = line
341 text = fmt.value(a, b)
342 elif len(line) == 3:
343 old, base, new = line
344 text = fmt.merge_value(old, base, new)
346 painter.drawText(
347 rect.x(),
348 rect.y(),
349 self.width() - (defs.margin * 2),
350 rect.height(),
351 Qt.AlignRight | Qt.AlignVCenter,
352 text,
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
370 self.images = []
371 self.pixmaps = []
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)
384 # Observe images
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()
411 return state
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)
425 return 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)
433 self.render()
434 else:
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)
454 def reset(self):
455 self.image.pixmap = QtGui.QPixmap()
456 self.cleanup()
458 def cleanup(self):
459 for (image, unlink) in self.images:
460 if unlink and core.exists(image):
461 os.unlink(image)
462 self.images = []
464 def set_images(self, images):
465 self.images = images
466 self.pixmaps = []
467 if not images:
468 self.reset()
469 return False
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()]
474 if not pixmaps:
475 self.reset()
476 return False
478 self.pixmaps = pixmaps
479 self.render()
480 self.cleanup()
481 return True
483 def render(self):
484 # Update images
485 if self.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()
495 else:
496 image = self.render_side_by_side()
497 else:
498 image = QtGui.QPixmap()
499 self.image.pixmap = image
501 # Apply zoom
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)
517 # Paint each pixmap
518 painter = create_painter(image)
519 x = 0
520 for pixmap in pixmaps:
521 painter.drawPixmap(x, 0, pixmap)
522 x += pixmap.width()
523 painter.end()
525 return image
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:
531 return pixmaps[0]
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)
543 painter.end()
545 return image
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)
564 return image
567 def create_painter(image):
568 painter = QtGui.QPainter(image)
569 painter.fillRect(image.rect(), Qt.transparent)
570 return painter
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
581 SIDE_BY_SIDE = 0
582 DIFF = 1
583 XOR = 2
584 PIXEL_XOR = 3
586 def __init__(self, parent):
587 super(Options, self).__init__(parent)
588 # Create widgets
589 self.widget = 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),
626 (N_('25%'), 0.25),
627 (N_('50%'), 0.5),
628 (N_('100%'), 1.0),
629 (N_('200%'), 2.0),
630 (N_('400%'), 4.0),
631 (N_('800%'), 8.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)
641 menu.addSeparator()
642 menu.addAction(self.function_context)
643 menu.addAction(self.show_line_numbers)
644 menu.addSeparator()
645 menu.addAction(self.enable_word_wrapping)
647 # Layouts
648 layout = qtutils.hbox(
649 defs.no_margin,
650 defs.button_spacing,
651 self.image_mode,
652 self.zoom_mode,
653 self.options,
654 self.toggle_image_diff,
656 self.setLayout(layout)
658 # Policies
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)
677 if is_image:
678 self.toggle_image_diff.setIcon(icons.diff())
679 else:
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)
686 return action
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):
710 up = Signal()
711 down = Signal()
712 options_changed = Signal()
713 updated = 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)
761 def refresh(self):
762 enabled = False
763 s = self.selection_model.selection()
764 model = self.model
765 if model.stageable():
766 item = s.modified[0] if s.modified else None
767 if item in model.submodules:
768 pass
769 elif item not in model.unstaged_deleted:
770 enabled = True
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)
776 if update:
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"""
785 if update:
786 with qtutils.BlockSignals(self.options.enable_word_wrapping):
787 self.options.enable_word_wrapping.setChecked(enabled)
788 if enabled:
789 self.setWordWrapMode(QtGui.QTextOption.WordWrap)
790 self.setLineWrapMode(QtWidgets.QPlainTextEdit.WidgetWidth)
791 else:
792 self.setWordWrapMode(QtGui.QTextOption.NoWrap)
793 self.setLineWrapMode(QtWidgets.QPlainTextEdit.NoWrap)
795 def set_options(self):
796 self.options_changed.emit()
798 # Qt overrides
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
803 model = self.model
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'))
810 else:
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(
819 icons.add(),
820 cmds.Stage.name(),
821 cmds.run(cmds.Stage, context, s.modified),
823 action.setShortcut(hotkeys.STAGE_SELECTION)
824 menu.addAction(
825 icons.cola(),
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...')
833 else:
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():
846 item = s.staged[0]
847 if item in model.submodules:
848 path = core.abspath(item)
849 action = menu.addAction(
850 icons.remove(),
851 cmds.Unstage.name(),
852 cmds.run(cmds.Unstage, context, s.staged),
854 action.setShortcut(hotkeys.STAGE_SELECTION)
855 menu.addAction(
856 icons.cola(),
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')
863 else:
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):
874 menu.addSeparator()
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
882 menu.addSeparator()
883 menu.addAction(self.move_up)
884 menu.addAction(self.move_down)
886 menu.addSeparator()
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"""
907 model = self.model
908 mode = model.mode
909 highlight = mode not in (model.mode_none, model.mode_untracked)
910 self.highlighter.set_enabled(highlight)
912 scrollbar = self.verticalScrollBar()
913 if scrollbar:
914 scrollvalue = get(scrollbar)
915 else:
916 scrollvalue = None
918 if text is None:
919 return
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)
931 first_line_idx = -1
932 last_line_idx = -1
933 line_idx = 0
934 line_start = 0
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
942 break
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):
954 model = self.model
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')
967 else:
968 title = N_('Revert Diff Hunk?')
969 ok_text = N_('Revert Diff Hunk')
971 if not Interaction.confirm(
972 title,
974 'This operation drops uncommitted changes.\n'
975 'These changes cannot be recovered.'
977 N_('Revert the uncommitted changes?'),
978 ok_text,
979 default=True,
980 icon=icons.undo(),
982 return
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():
988 return
989 context = self.context
990 first_line_idx, last_line_idx = self.selected_lines()
991 cmds.do(
992 cmds.ApplyDiffSelection,
993 context,
994 first_line_idx,
995 last_line_idx,
996 self.has_selection(),
997 reverse,
998 apply_to_worktree,
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
1011 self.oid = 'HEAD'
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(
1054 defs.no_margin,
1055 defs.no_spacing,
1056 self.author_label,
1057 self.date_label,
1058 self.summary_label,
1059 self.oid_label,
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:
1088 return
1089 commit = commits[0]
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}
1103 author_text = (
1104 """%(author)s &lt;"""
1105 """<a href="mailto:%(email)s">"""
1106 """%(email)s</a>&gt;""" % 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):
1118 if not filenames:
1119 return
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
1129 self._display = ''
1130 self._template = ''
1131 self._text = ''
1132 self._elide = False
1133 self._metrics = QtGui.QFontMetrics(self.font())
1134 self.setOpenExternalLinks(True)
1136 def elide(self):
1137 self._elide = True
1139 def set_text(self, text):
1140 self.set_template(text, text)
1142 def set_template(self, text, template):
1143 self._display = text
1144 self._text = 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
1151 if not self._elide:
1152 return
1153 text = self._metrics.elidedText(self._template, Qt.ElideRight, width - 2)
1154 if text != self._template:
1155 self._display = text
1157 # Qt overrides
1158 def setFont(self, font):
1159 self._metrics = QtGui.QFontMetrics(font)
1160 QtWidgets.QLabel.setFont(self, font)
1162 def resizeEvent(self, event):
1163 if self._elide:
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
1174 self.oid = oid
1175 self.filename = filename
1177 def task(self):
1178 context = self.context
1179 oid = self.oid
1180 return gitcmds.diff_info(context, oid, filename=self.filename)