diff: organize and reorder the diff options menu
[git-cola.git] / cola / widgets / diff.py
blob9da6f2352cc4f30fba11a7fe91fb121cd4ce2cb4
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.enable_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.enable_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 update_options(self):
443 self.text.update_options()
445 def enable_line_numbers(self, enabled, update=False):
446 """Enable/disable line numbers in the text widget"""
447 self.text.enable_line_numbers(enabled, update=update)
449 def enable_word_wrapping(self, enabled, update=False):
450 """Enable/disable word wrapping in the text widget"""
451 self.text.enable_word_wrapping(enabled, update=update)
453 def reset(self):
454 self.image.pixmap = QtGui.QPixmap()
455 self.cleanup()
457 def cleanup(self):
458 for (image, unlink) in self.images:
459 if unlink and core.exists(image):
460 os.unlink(image)
461 self.images = []
463 def set_images(self, images):
464 self.images = images
465 self.pixmaps = []
466 if not images:
467 self.reset()
468 return False
470 # In order to comp, we first have to load all the images
471 all_pixmaps = [QtGui.QPixmap(image[0]) for image in images]
472 pixmaps = [pixmap for pixmap in all_pixmaps if not pixmap.isNull()]
473 if not pixmaps:
474 self.reset()
475 return False
477 self.pixmaps = pixmaps
478 self.render()
479 self.cleanup()
480 return True
482 def render(self):
483 # Update images
484 if self.pixmaps:
485 mode = self.options.image_mode.currentIndex()
486 if mode == self.options.SIDE_BY_SIDE:
487 image = self.render_side_by_side()
488 elif mode == self.options.DIFF:
489 image = self.render_diff()
490 elif mode == self.options.XOR:
491 image = self.render_xor()
492 elif mode == self.options.PIXEL_XOR:
493 image = self.render_pixel_xor()
494 else:
495 image = self.render_side_by_side()
496 else:
497 image = QtGui.QPixmap()
498 self.image.pixmap = image
500 # Apply zoom
501 zoom_mode = self.options.zoom_mode.currentIndex()
502 zoom_factor = self.options.zoom_factors[zoom_mode][1]
503 if zoom_factor > 0.0:
504 self.image.resetTransform()
505 self.image.scale(zoom_factor, zoom_factor)
506 poly = self.image.mapToScene(self.image.viewport().rect())
507 self.image.last_scene_roi = poly.boundingRect()
509 def render_side_by_side(self):
510 # Side-by-side lineup comp
511 pixmaps = self.pixmaps
512 width = sum([pixmap.width() for pixmap in pixmaps])
513 height = max([pixmap.height() for pixmap in pixmaps])
514 image = create_image(width, height)
516 # Paint each pixmap
517 painter = create_painter(image)
518 x = 0
519 for pixmap in pixmaps:
520 painter.drawPixmap(x, 0, pixmap)
521 x += pixmap.width()
522 painter.end()
524 return image
526 def render_comp(self, comp_mode):
527 # Get the max size to use as the render canvas
528 pixmaps = self.pixmaps
529 if len(pixmaps) == 1:
530 return pixmaps[0]
532 width = max([pixmap.width() for pixmap in pixmaps])
533 height = max([pixmap.height() for pixmap in pixmaps])
534 image = create_image(width, height)
536 painter = create_painter(image)
537 for pixmap in (pixmaps[0], pixmaps[-1]):
538 x = (width - pixmap.width()) // 2
539 y = (height - pixmap.height()) // 2
540 painter.drawPixmap(x, y, pixmap)
541 painter.setCompositionMode(comp_mode)
542 painter.end()
544 return image
546 def render_diff(self):
547 comp_mode = QtGui.QPainter.CompositionMode_Difference
548 return self.render_comp(comp_mode)
550 def render_xor(self):
551 comp_mode = QtGui.QPainter.CompositionMode_Xor
552 return self.render_comp(comp_mode)
554 def render_pixel_xor(self):
555 comp_mode = QtGui.QPainter.RasterOp_SourceXorDestination
556 return self.render_comp(comp_mode)
559 def create_image(width, height):
560 size = QtCore.QSize(width, height)
561 image = QtGui.QImage(size, QtGui.QImage.Format_ARGB32_Premultiplied)
562 image.fill(Qt.transparent)
563 return image
566 def create_painter(image):
567 painter = QtGui.QPainter(image)
568 painter.fillRect(image.rect(), Qt.transparent)
569 return painter
572 class Options(QtWidgets.QWidget):
573 """Provide the options widget used by the editor
575 Actions are registered on the parent widget.
579 # mode combobox indexes
580 SIDE_BY_SIDE = 0
581 DIFF = 1
582 XOR = 2
583 PIXEL_XOR = 3
585 def __init__(self, parent):
586 super(Options, self).__init__(parent)
587 # Create widgets
588 self.widget = parent
589 self.ignore_space_at_eol = self.add_option(
590 N_('Ignore changes in whitespace at EOL')
593 self.ignore_space_change = self.add_option(
594 N_('Ignore changes in amount of whitespace')
597 self.ignore_all_space = self.add_option(N_('Ignore all whitespace'))
599 self.function_context = self.add_option(
600 N_('Show whole surrounding functions of changes')
603 self.show_line_numbers = qtutils.add_action_bool(
604 self, N_('Show line numbers'), self.update_show_line_numbers, True
606 self.enable_word_wrapping = qtutils.add_action_bool(
607 self, N_('Enable word wrapping'), self.update_word_wrapping, True
610 self.options = qtutils.create_action_button(
611 tooltip=N_('Diff Options'), icon=icons.configure()
614 self.toggle_image_diff = qtutils.create_action_button(
615 tooltip=N_('Toggle image diff'), icon=icons.visualize()
617 self.toggle_image_diff.hide()
619 self.image_mode = qtutils.combo(
620 [N_('Side by side'), N_('Diff'), N_('XOR'), N_('Pixel XOR')]
623 self.zoom_factors = (
624 (N_('Zoom to Fit'), 0.0),
625 (N_('25%'), 0.25),
626 (N_('50%'), 0.5),
627 (N_('100%'), 1.0),
628 (N_('200%'), 2.0),
629 (N_('400%'), 4.0),
630 (N_('800%'), 8.0),
632 zoom_modes = [factor[0] for factor in self.zoom_factors]
633 self.zoom_mode = qtutils.combo(zoom_modes, parent=self)
635 self.menu = menu = qtutils.create_menu(N_('Diff Options'), self.options)
636 self.options.setMenu(menu)
637 menu.addAction(self.ignore_space_at_eol)
638 menu.addAction(self.ignore_space_change)
639 menu.addAction(self.ignore_all_space)
640 menu.addSeparator()
641 menu.addAction(self.function_context)
642 menu.addAction(self.show_line_numbers)
643 menu.addSeparator()
644 menu.addAction(self.enable_word_wrapping)
646 # Layouts
647 layout = qtutils.hbox(
648 defs.no_margin,
649 defs.button_spacing,
650 self.image_mode,
651 self.zoom_mode,
652 self.options,
653 self.toggle_image_diff,
655 self.setLayout(layout)
657 # Policies
658 self.image_mode.setFocusPolicy(Qt.NoFocus)
659 self.zoom_mode.setFocusPolicy(Qt.NoFocus)
660 self.options.setFocusPolicy(Qt.NoFocus)
661 self.toggle_image_diff.setFocusPolicy(Qt.NoFocus)
662 self.setFocusPolicy(Qt.NoFocus)
664 def set_file_type(self, file_type):
665 """Set whether we are viewing an image file type"""
666 is_image = file_type == main.Types.IMAGE
667 self.toggle_image_diff.setVisible(is_image)
669 def set_diff_type(self, diff_type):
670 """Toggle between image and text diffs"""
671 is_text = diff_type == main.Types.TEXT
672 is_image = diff_type == main.Types.IMAGE
673 self.options.setVisible(is_text)
674 self.image_mode.setVisible(is_image)
675 self.zoom_mode.setVisible(is_image)
676 if is_image:
677 self.toggle_image_diff.setIcon(icons.diff())
678 else:
679 self.toggle_image_diff.setIcon(icons.visualize())
681 def add_option(self, title):
682 """Add a diff option which calls update_options() on change"""
683 action = qtutils.add_action(self, title, self.update_options)
684 action.setCheckable(True)
685 return action
687 def update_options(self):
688 """Update diff options in response to UI events"""
689 space_at_eol = get(self.ignore_space_at_eol)
690 space_change = get(self.ignore_space_change)
691 all_space = get(self.ignore_all_space)
692 function_context = get(self.function_context)
693 gitcmds.update_diff_overrides(
694 space_at_eol, space_change, all_space, function_context
696 self.widget.update_options()
698 def update_show_line_numbers(self, value):
699 self.widget.enable_line_numbers(value, update=False)
701 def update_word_wrapping(self, value):
702 """Respond to Qt action callbacks"""
703 self.widget.enable_word_wrapping(value, update=False)
706 # pylint: disable=too-many-ancestors
707 class DiffEditor(DiffTextEdit):
709 up = Signal()
710 down = Signal()
711 options_changed = Signal()
712 updated = Signal()
713 diff_text_updated = Signal(object)
715 def __init__(self, context, options, parent):
716 DiffTextEdit.__init__(self, context, parent, numbers=True)
717 self.context = context
718 self.model = model = context.model
719 self.selection_model = selection_model = context.selection
721 # "Diff Options" tool menu
722 self.options = options
724 self.action_apply_selection = qtutils.add_action(
725 self, 'Apply', self.apply_selection, hotkeys.STAGE_DIFF
728 self.action_revert_selection = qtutils.add_action(
729 self, 'Revert', self.revert_selection, hotkeys.REVERT
731 self.action_revert_selection.setIcon(icons.undo())
733 self.launch_editor = actions.launch_editor_at_line(
734 context, self, hotkeys.EDIT_SHORT, *hotkeys.ACCEPT
736 self.launch_difftool = actions.launch_difftool(context, self)
737 self.stage_or_unstage = actions.stage_or_unstage(context, self)
739 # Emit up/down signals so that they can be routed by the main widget
740 self.move_up = actions.move_up(self)
741 self.move_down = actions.move_down(self)
743 diff_text_updated = model.message_diff_text_updated
744 model.add_observer(diff_text_updated, self.diff_text_updated.emit)
745 self.diff_text_updated.connect(self.set_diff, type=Qt.QueuedConnection)
747 selection_model.add_observer(
748 selection_model.message_selection_changed, self.updated.emit
750 # pylint: disable=no-member
751 self.updated.connect(self.refresh, type=Qt.QueuedConnection)
752 # Update the selection model when the cursor changes
753 self.cursorPositionChanged.connect(self._update_line_number)
755 qtutils.connect_button(options.toggle_image_diff, self.toggle_diff_type)
757 def toggle_diff_type(self):
758 cmds.do(cmds.ToggleDiffType, self.context)
760 def refresh(self):
761 enabled = False
762 s = self.selection_model.selection()
763 model = self.model
764 if s.modified and model.stageable():
765 if s.modified[0] in model.submodules:
766 pass
767 elif s.modified[0] not in model.unstaged_deleted:
768 enabled = True
769 self.action_revert_selection.setEnabled(enabled)
771 def enable_line_numbers(self, enabled, update=False):
772 """Enable/disable the diff line number display"""
773 self.numbers.setVisible(enabled)
774 if update:
775 signals = self.options.show_line_numbers.blockSignals(True)
776 self.options.show_line_numbers.setChecked(enabled)
777 self.options.show_line_numbers.blockSignals(signals)
778 # Refresh the display. Not doing this results in the display not
779 # correctly displaying the line numbers widget until the text scrolls.
780 self.set_value(self.value())
782 def enable_word_wrapping(self, enabled, update=False):
783 """Enable/disable word wrapping"""
784 if update:
785 signals = self.options.enable_word_wrapping.blockSignals(True)
786 self.options.enable_word_wrapping.setChecked(enabled)
787 self.options.enable_word_wrapping.blockSignals(signals)
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 update_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 s.modified and model.stageable():
815 item = s.modified[0]
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:
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 block = self.blockSignals(True)
1166 self.setText(self._display)
1167 self.blockSignals(block)
1168 QtWidgets.QLabel.resizeEvent(self, event)
1171 class DiffInfoTask(qtutils.Task):
1172 def __init__(self, context, oid, filename, parent):
1173 qtutils.Task.__init__(self, parent)
1174 self.context = context
1175 self.oid = oid
1176 self.filename = filename
1178 def task(self):
1179 context = self.context
1180 oid = self.oid
1181 return gitcmds.diff_info(context, oid, filename=self.filename)