widgets: consolidate context menu handling for text widgets
[git-cola.git] / cola / widgets / diff.py
blob9716b4dbe290acee313bf990c13e25118d3c9dcd
1 from __future__ import absolute_import, division, print_function, unicode_literals
2 from functools import partial
3 import os
4 import re
6 from qtpy import QtCore
7 from qtpy import QtGui
8 from qtpy import QtWidgets
9 from qtpy.QtCore import Qt
10 from qtpy.QtCore import Signal
12 from ..i18n import N_
13 from ..editpatch import edit_patch
14 from ..interaction import Interaction
15 from ..models import main
16 from ..models import prefs
17 from ..qtutils import get
18 from .. import actions
19 from .. import cmds
20 from .. import core
21 from .. import diffparse
22 from .. import gitcmds
23 from .. import gravatar
24 from .. import hotkeys
25 from .. import icons
26 from .. import utils
27 from .. import qtutils
28 from .text import TextDecorator
29 from .text import VimHintedPlainTextEdit
30 from . import defs
31 from . import imageview
34 class DiffSyntaxHighlighter(QtGui.QSyntaxHighlighter):
35 """Implements the diff syntax highlighting"""
37 INITIAL_STATE = -1
38 DEFAULT_STATE = 0
39 DIFFSTAT_STATE = 1
40 DIFF_FILE_HEADER_STATE = 2
41 DIFF_STATE = 3
42 SUBMODULE_STATE = 4
43 END_STATE = 5
45 DIFF_FILE_HEADER_START_RGX = re.compile(r'diff --git a/.* b/.*')
46 DIFF_HUNK_HEADER_RGX = re.compile(
47 r'(?:@@ -[0-9,]+ \+[0-9,]+ @@)|(?:@@@ (?:-[0-9,]+ ){2}\+[0-9,]+ @@@)'
49 BAD_WHITESPACE_RGX = re.compile(r'\s+$')
51 def __init__(self, context, doc, whitespace=True, is_commit=False):
52 QtGui.QSyntaxHighlighter.__init__(self, doc)
53 self.whitespace = whitespace
54 self.enabled = True
55 self.is_commit = is_commit
57 QPalette = QtGui.QPalette
58 cfg = context.cfg
59 palette = QPalette()
60 disabled = palette.color(QPalette.Disabled, QPalette.Text)
61 header = qtutils.rgb_hex(disabled)
63 dark = palette.color(QPalette.Base).lightnessF() < 0.5
65 self.color_text = qtutils.RGB(cfg.color('text', '030303'))
66 self.color_add = qtutils.RGB(cfg.color('add', '77aa77' if dark else 'd2ffe4'))
67 self.color_remove = qtutils.RGB(
68 cfg.color('remove', 'aa7777' if dark else 'fee0e4')
70 self.color_header = qtutils.RGB(cfg.color('header', header))
72 self.diff_header_fmt = qtutils.make_format(fg=self.color_header)
73 self.bold_diff_header_fmt = qtutils.make_format(fg=self.color_header, bold=True)
75 self.diff_add_fmt = qtutils.make_format(fg=self.color_text, bg=self.color_add)
76 self.diff_remove_fmt = qtutils.make_format(
77 fg=self.color_text, bg=self.color_remove
79 self.bad_whitespace_fmt = qtutils.make_format(bg=Qt.red)
80 self.setCurrentBlockState(self.INITIAL_STATE)
82 def set_enabled(self, enabled):
83 self.enabled = enabled
85 def highlightBlock(self, text):
86 if not self.enabled or not text:
87 return
88 # Aliases for quick local access
89 initial_state = self.INITIAL_STATE
90 default_state = self.DEFAULT_STATE
91 diff_state = self.DIFF_STATE
92 diffstat_state = self.DIFFSTAT_STATE
93 diff_file_header_state = self.DIFF_FILE_HEADER_STATE
94 submodule_state = self.SUBMODULE_STATE
95 end_state = self.END_STATE
97 diff_file_header_start_rgx = self.DIFF_FILE_HEADER_START_RGX
98 diff_hunk_header_rgx = self.DIFF_HUNK_HEADER_RGX
99 bad_whitespace_rgx = self.BAD_WHITESPACE_RGX
101 diff_header_fmt = self.diff_header_fmt
102 bold_diff_header_fmt = self.bold_diff_header_fmt
103 diff_add_fmt = self.diff_add_fmt
104 diff_remove_fmt = self.diff_remove_fmt
105 bad_whitespace_fmt = self.bad_whitespace_fmt
107 state = self.previousBlockState()
108 if state == initial_state:
109 if text.startswith('Submodule '):
110 state = submodule_state
111 elif text.startswith('diff --git '):
112 state = diffstat_state
113 elif self.is_commit:
114 state = default_state
115 else:
116 state = diffstat_state
118 if state == diffstat_state:
119 if diff_file_header_start_rgx.match(text):
120 state = diff_file_header_state
121 self.setFormat(0, len(text), diff_header_fmt)
122 elif diff_hunk_header_rgx.match(text):
123 state = diff_state
124 self.setFormat(0, len(text), bold_diff_header_fmt)
125 elif '|' in text:
126 i = text.index('|')
127 self.setFormat(0, i, bold_diff_header_fmt)
128 self.setFormat(i, len(text) - i, diff_header_fmt)
129 else:
130 self.setFormat(0, len(text), diff_header_fmt)
131 elif state == diff_file_header_state:
132 if diff_hunk_header_rgx.match(text):
133 state = diff_state
134 self.setFormat(0, len(text), bold_diff_header_fmt)
135 else:
136 self.setFormat(0, len(text), diff_header_fmt)
137 elif state == diff_state:
138 if diff_file_header_start_rgx.match(text):
139 state = diff_file_header_state
140 self.setFormat(0, len(text), diff_header_fmt)
141 elif diff_hunk_header_rgx.match(text):
142 self.setFormat(0, len(text), bold_diff_header_fmt)
143 elif text.startswith('-'):
144 if text == '-- ':
145 state = end_state
146 else:
147 self.setFormat(0, len(text), diff_remove_fmt)
148 elif text.startswith('+'):
149 self.setFormat(0, len(text), diff_add_fmt)
150 if self.whitespace:
151 m = bad_whitespace_rgx.search(text)
152 if m is not None:
153 i = m.start()
154 self.setFormat(i, len(text) - i, bad_whitespace_fmt)
156 self.setCurrentBlockState(state)
159 # pylint: disable=too-many-ancestors
160 class DiffTextEdit(VimHintedPlainTextEdit):
161 """A textedit for interacting with diff text"""
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
178 self.copy_diff_action = qtutils.add_action(
179 self,
180 N_('Copy Diff'),
181 self.copy_diff,
182 hotkeys.COPY_DIFF,
184 self.copy_diff_action.setIcon(icons.copy())
185 self.copy_diff_action.setEnabled(False)
186 self.menu_actions.append(self.copy_diff_action)
188 # pylint: disable=no-member
189 self.cursorPositionChanged.connect(self._cursor_changed, Qt.QueuedConnection)
190 self.selectionChanged.connect(self._selection_changed, Qt.QueuedConnection)
192 def setFont(self, font):
193 """Override setFont() so that we can use a custom "block" cursor"""
194 super(DiffTextEdit, self).setFont(font)
195 if prefs.block_cursor(self.context):
196 metrics = QtGui.QFontMetrics(font)
197 width = metrics.width('M')
198 self.setCursorWidth(width)
200 def _cursor_changed(self):
201 """Update the line number display when the cursor changes"""
202 line_number = max(0, self.textCursor().blockNumber())
203 if self.numbers:
204 self.numbers.set_highlighted(line_number)
206 def _selection_changed(self):
207 """Respond to selection changes"""
208 selected = bool(self.selected_text())
209 self.copy_diff_action.setEnabled(selected)
211 def resizeEvent(self, event):
212 super(DiffTextEdit, self).resizeEvent(event)
213 if self.numbers:
214 self.numbers.refresh_size()
216 def save_scrollbar(self):
217 """Save the scrollbar value, but only on the first call"""
218 if self.scrollvalue is None:
219 scrollbar = self.verticalScrollBar()
220 if scrollbar:
221 scrollvalue = get(scrollbar)
222 else:
223 scrollvalue = None
224 self.scrollvalue = scrollvalue
226 def restore_scrollbar(self):
227 """Restore the scrollbar and clear state"""
228 scrollbar = self.verticalScrollBar()
229 scrollvalue = self.scrollvalue
230 if scrollbar and scrollvalue is not None:
231 scrollbar.setValue(scrollvalue)
232 self.scrollvalue = None
234 def set_loading_message(self):
235 """Add a pending loading message in the diff view"""
236 self.hint.set_value('+++ ' + N_('Loading...'))
237 self.set_value('')
239 def set_diff(self, diff):
240 """Set the diff text, but save the scrollbar"""
241 diff = diff.rstrip('\n') # diffs include two empty newlines
242 self.save_scrollbar()
244 self.hint.set_value('')
245 if self.numbers:
246 self.numbers.set_diff(diff)
247 self.set_value(diff)
249 self.restore_scrollbar()
251 def selected_diff_stripped(self):
252 """Return the selected diff stripped of any diff characters"""
253 sep, selection = self.selected_text_lines()
254 return sep.join(_strip_diff(line) for line in selection)
256 def copy_diff(self):
257 """Copy the selected diff text stripped of any diff prefix characters"""
258 text = self.selected_diff_stripped()
259 qtutils.set_clipboard(text)
262 def _get_sep(text):
263 """Return either CRLF or LF based on the content"""
264 if '\r\n' in text:
265 sep = '\r\n'
266 else:
267 sep = '\n'
268 return sep
271 def _strip_diff(value):
272 """Remove +/-/<space> from a selection"""
273 if value.startswith(('+', '-', ' ')):
274 return value[1:]
275 return value
278 class DiffLineNumbers(TextDecorator):
279 def __init__(self, context, parent):
280 TextDecorator.__init__(self, parent)
281 self.highlight_line = -1
282 self.lines = None
283 self.parser = diffparse.DiffLines()
284 self.formatter = diffparse.FormatDigits()
286 self.setFont(qtutils.diff_font(context))
287 self._char_width = self.fontMetrics().width('0')
289 QPalette = QtGui.QPalette
290 self._palette = palette = self.palette()
291 self._base = palette.color(QtGui.QPalette.Base)
292 self._highlight = palette.color(QPalette.Highlight)
293 self._highlight.setAlphaF(0.3)
294 self._highlight_text = palette.color(QPalette.HighlightedText)
295 self._window = palette.color(QPalette.Window)
296 self._disabled = palette.color(QPalette.Disabled, QPalette.Text)
298 def set_diff(self, diff):
299 self.lines = self.parser.parse(diff)
300 self.formatter.set_digits(self.parser.digits())
302 def width_hint(self):
303 if not self.isVisible():
304 return 0
305 parser = self.parser
307 if parser.merge:
308 columns = 3
309 extra = 3 # one space in-between, one space after
310 else:
311 columns = 2
312 extra = 2 # one space in-between, one space after
314 digits = parser.digits() * columns
316 return defs.margin + (self._char_width * (digits + extra))
318 def set_highlighted(self, line_number):
319 """Set the line to highlight"""
320 self.highlight_line = line_number
322 def current_line(self):
323 lines = self.lines
324 if lines and self.highlight_line >= 0:
325 # Find the next valid line
326 for i in range(self.highlight_line, len(lines)):
327 # take the "new" line number: last value in tuple
328 line_number = lines[i][-1]
329 if line_number > 0:
330 return line_number
332 # Find the previous valid line
333 for i in range(self.highlight_line - 1, -1, -1):
334 # take the "new" line number: last value in tuple
335 if i < len(lines):
336 line_number = lines[i][-1]
337 if line_number > 0:
338 return line_number
339 return None
341 def paintEvent(self, event):
342 """Paint the line number"""
343 if not self.lines:
344 return
346 painter = QtGui.QPainter(self)
347 painter.fillRect(event.rect(), self._base)
349 editor = self.editor
350 content_offset = editor.contentOffset()
351 block = editor.firstVisibleBlock()
352 width = self.width()
353 text_width = width - (defs.margin * 2)
354 text_flags = Qt.AlignRight | Qt.AlignVCenter
355 event_rect_bottom = event.rect().bottom()
357 highlight_line = self.highlight_line
358 highlight = self._highlight
359 highlight_text = self._highlight_text
360 disabled = self._disabled
362 fmt = self.formatter
363 lines = self.lines
364 num_lines = len(lines)
366 while block.isValid():
367 block_number = block.blockNumber()
368 if block_number >= num_lines:
369 break
370 block_geom = editor.blockBoundingGeometry(block)
371 rect = block_geom.translated(content_offset).toRect()
372 if not block.isVisible() or rect.top() >= event_rect_bottom:
373 break
375 if block_number == highlight_line:
376 painter.fillRect(rect.x(), rect.y(), width, rect.height(), highlight)
377 painter.setPen(highlight_text)
378 else:
379 painter.setPen(disabled)
381 line = lines[block_number]
382 if len(line) == 2:
383 a, b = line
384 text = fmt.value(a, b)
385 elif len(line) == 3:
386 old, base, new = line
387 text = fmt.merge_value(old, base, new)
389 painter.drawText(
390 rect.x(),
391 rect.y(),
392 text_width,
393 rect.height(),
394 text_flags,
395 text,
398 block = block.next() # pylint: disable=next-method-called
401 class Viewer(QtWidgets.QFrame):
402 """Text and image diff viewers"""
404 def __init__(self, context, parent=None):
405 super(Viewer, self).__init__(parent)
407 self.context = context
408 self.model = model = context.model
409 self.images = []
410 self.pixmaps = []
411 self.options = options = Options(self)
412 self.text = DiffEditor(context, options, self)
413 self.image = imageview.ImageView(parent=self)
414 self.image.setFocusPolicy(Qt.NoFocus)
416 stack = self.stack = QtWidgets.QStackedWidget(self)
417 stack.addWidget(self.text)
418 stack.addWidget(self.image)
420 self.main_layout = qtutils.vbox(defs.no_margin, defs.no_spacing, self.stack)
421 self.setLayout(self.main_layout)
423 # Observe images
424 model.images_changed.connect(self.set_images, type=Qt.QueuedConnection)
426 # Observe the diff type
427 model.diff_type_changed.connect(self.set_diff_type, type=Qt.QueuedConnection)
429 # Observe the file type
430 model.file_type_changed.connect(self.set_file_type, type=Qt.QueuedConnection)
432 # Observe the image mode combo box
433 options.image_mode.currentIndexChanged.connect(lambda _: self.render())
434 options.zoom_mode.currentIndexChanged.connect(lambda _: self.render())
436 self.setFocusProxy(self.text)
438 def export_state(self, state):
439 state['show_diff_line_numbers'] = self.options.show_line_numbers.isChecked()
440 state['image_diff_mode'] = self.options.image_mode.currentIndex()
441 state['image_zoom_mode'] = self.options.zoom_mode.currentIndex()
442 state['word_wrap'] = self.options.enable_word_wrapping.isChecked()
443 return state
445 def apply_state(self, state):
446 diff_numbers = bool(state.get('show_diff_line_numbers', False))
447 self.set_line_numbers(diff_numbers, update=True)
449 image_mode = utils.asint(state.get('image_diff_mode', 0))
450 self.options.image_mode.set_index(image_mode)
452 zoom_mode = utils.asint(state.get('image_zoom_mode', 0))
453 self.options.zoom_mode.set_index(zoom_mode)
455 word_wrap = bool(state.get('word_wrap', True))
456 self.set_word_wrapping(word_wrap, update=True)
457 return True
459 def set_diff_type(self, diff_type):
460 """Manage the image and text diff views when selection changes"""
461 # The "diff type" is whether the diff viewer is displaying an image.
462 self.options.set_diff_type(diff_type)
463 if diff_type == main.Types.IMAGE:
464 self.stack.setCurrentWidget(self.image)
465 self.render()
466 else:
467 self.stack.setCurrentWidget(self.text)
469 def set_file_type(self, file_type):
470 """Manage the diff options when the file type changes"""
471 # The "file type" is whether the file itself is an image.
472 self.options.set_file_type(file_type)
474 def update_options(self):
475 """Emit a signal indicating that options have changed"""
476 self.text.update_options()
478 def set_line_numbers(self, enabled, update=False):
479 """Enable/disable line numbers in the text widget"""
480 self.text.set_line_numbers(enabled, update=update)
482 def set_word_wrapping(self, enabled, update=False):
483 """Enable/disable word wrapping in the text widget"""
484 self.text.set_word_wrapping(enabled, update=update)
486 def reset(self):
487 self.image.pixmap = QtGui.QPixmap()
488 self.cleanup()
490 def cleanup(self):
491 for (image, unlink) in self.images:
492 if unlink and core.exists(image):
493 os.unlink(image)
494 self.images = []
496 def set_images(self, images):
497 self.images = images
498 self.pixmaps = []
499 if not images:
500 self.reset()
501 return False
503 # In order to comp, we first have to load all the images
504 all_pixmaps = [QtGui.QPixmap(image[0]) for image in images]
505 pixmaps = [pixmap for pixmap in all_pixmaps if not pixmap.isNull()]
506 if not pixmaps:
507 self.reset()
508 return False
510 self.pixmaps = pixmaps
511 self.render()
512 self.cleanup()
513 return True
515 def render(self):
516 # Update images
517 if self.pixmaps:
518 mode = self.options.image_mode.currentIndex()
519 if mode == self.options.SIDE_BY_SIDE:
520 image = self.render_side_by_side()
521 elif mode == self.options.DIFF:
522 image = self.render_diff()
523 elif mode == self.options.XOR:
524 image = self.render_xor()
525 elif mode == self.options.PIXEL_XOR:
526 image = self.render_pixel_xor()
527 else:
528 image = self.render_side_by_side()
529 else:
530 image = QtGui.QPixmap()
531 self.image.pixmap = image
533 # Apply zoom
534 zoom_mode = self.options.zoom_mode.currentIndex()
535 zoom_factor = self.options.zoom_factors[zoom_mode][1]
536 if zoom_factor > 0.0:
537 self.image.resetTransform()
538 self.image.scale(zoom_factor, zoom_factor)
539 poly = self.image.mapToScene(self.image.viewport().rect())
540 self.image.last_scene_roi = poly.boundingRect()
542 def render_side_by_side(self):
543 # Side-by-side lineup comp
544 pixmaps = self.pixmaps
545 width = sum(pixmap.width() for pixmap in pixmaps)
546 height = max(pixmap.height() for pixmap in pixmaps)
547 image = create_image(width, height)
549 # Paint each pixmap
550 painter = create_painter(image)
551 x = 0
552 for pixmap in pixmaps:
553 painter.drawPixmap(x, 0, pixmap)
554 x += pixmap.width()
555 painter.end()
557 return image
559 def render_comp(self, comp_mode):
560 # Get the max size to use as the render canvas
561 pixmaps = self.pixmaps
562 if len(pixmaps) == 1:
563 return pixmaps[0]
565 width = max(pixmap.width() for pixmap in pixmaps)
566 height = max(pixmap.height() for pixmap in pixmaps)
567 image = create_image(width, height)
569 painter = create_painter(image)
570 for pixmap in (pixmaps[0], pixmaps[-1]):
571 x = (width - pixmap.width()) // 2
572 y = (height - pixmap.height()) // 2
573 painter.drawPixmap(x, y, pixmap)
574 painter.setCompositionMode(comp_mode)
575 painter.end()
577 return image
579 def render_diff(self):
580 comp_mode = QtGui.QPainter.CompositionMode_Difference
581 return self.render_comp(comp_mode)
583 def render_xor(self):
584 comp_mode = QtGui.QPainter.CompositionMode_Xor
585 return self.render_comp(comp_mode)
587 def render_pixel_xor(self):
588 comp_mode = QtGui.QPainter.RasterOp_SourceXorDestination
589 return self.render_comp(comp_mode)
592 def create_image(width, height):
593 size = QtCore.QSize(width, height)
594 image = QtGui.QImage(size, QtGui.QImage.Format_ARGB32_Premultiplied)
595 image.fill(Qt.transparent)
596 return image
599 def create_painter(image):
600 painter = QtGui.QPainter(image)
601 painter.fillRect(image.rect(), Qt.transparent)
602 return painter
605 class Options(QtWidgets.QWidget):
606 """Provide the options widget used by the editor
608 Actions are registered on the parent widget.
612 # mode combobox indexes
613 SIDE_BY_SIDE = 0
614 DIFF = 1
615 XOR = 2
616 PIXEL_XOR = 3
618 def __init__(self, parent):
619 super(Options, self).__init__(parent)
620 # Create widgets
621 self.widget = parent
622 self.ignore_space_at_eol = self.add_option(
623 N_('Ignore changes in whitespace at EOL')
626 self.ignore_space_change = self.add_option(
627 N_('Ignore changes in amount of whitespace')
630 self.ignore_all_space = self.add_option(N_('Ignore all whitespace'))
632 self.function_context = self.add_option(
633 N_('Show whole surrounding functions of changes')
636 self.show_line_numbers = qtutils.add_action_bool(
637 self, N_('Show line numbers'), self.set_line_numbers, True
639 self.enable_word_wrapping = qtutils.add_action_bool(
640 self, N_('Enable word wrapping'), self.set_word_wrapping, True
643 self.options = qtutils.create_action_button(
644 tooltip=N_('Diff Options'), icon=icons.configure()
647 self.toggle_image_diff = qtutils.create_action_button(
648 tooltip=N_('Toggle image diff'), icon=icons.visualize()
650 self.toggle_image_diff.hide()
652 self.image_mode = qtutils.combo(
653 [N_('Side by side'), N_('Diff'), N_('XOR'), N_('Pixel XOR')]
656 self.zoom_factors = (
657 (N_('Zoom to Fit'), 0.0),
658 (N_('25%'), 0.25),
659 (N_('50%'), 0.5),
660 (N_('100%'), 1.0),
661 (N_('200%'), 2.0),
662 (N_('400%'), 4.0),
663 (N_('800%'), 8.0),
665 zoom_modes = [factor[0] for factor in self.zoom_factors]
666 self.zoom_mode = qtutils.combo(zoom_modes, parent=self)
668 self.menu = menu = qtutils.create_menu(N_('Diff Options'), self.options)
669 self.options.setMenu(menu)
670 menu.addAction(self.ignore_space_at_eol)
671 menu.addAction(self.ignore_space_change)
672 menu.addAction(self.ignore_all_space)
673 menu.addSeparator()
674 menu.addAction(self.function_context)
675 menu.addAction(self.show_line_numbers)
676 menu.addSeparator()
677 menu.addAction(self.enable_word_wrapping)
679 # Layouts
680 layout = qtutils.hbox(
681 defs.no_margin,
682 defs.button_spacing,
683 self.image_mode,
684 self.zoom_mode,
685 self.options,
686 self.toggle_image_diff,
688 self.setLayout(layout)
690 # Policies
691 self.image_mode.setFocusPolicy(Qt.NoFocus)
692 self.zoom_mode.setFocusPolicy(Qt.NoFocus)
693 self.options.setFocusPolicy(Qt.NoFocus)
694 self.toggle_image_diff.setFocusPolicy(Qt.NoFocus)
695 self.setFocusPolicy(Qt.NoFocus)
697 def set_file_type(self, file_type):
698 """Set whether we are viewing an image file type"""
699 is_image = file_type == main.Types.IMAGE
700 self.toggle_image_diff.setVisible(is_image)
702 def set_diff_type(self, diff_type):
703 """Toggle between image and text diffs"""
704 is_text = diff_type == main.Types.TEXT
705 is_image = diff_type == main.Types.IMAGE
706 self.options.setVisible(is_text)
707 self.image_mode.setVisible(is_image)
708 self.zoom_mode.setVisible(is_image)
709 if is_image:
710 self.toggle_image_diff.setIcon(icons.diff())
711 else:
712 self.toggle_image_diff.setIcon(icons.visualize())
714 def add_option(self, title):
715 """Add a diff option which calls update_options() on change"""
716 action = qtutils.add_action(self, title, self.update_options)
717 action.setCheckable(True)
718 return action
720 def update_options(self):
721 """Update diff options in response to UI events"""
722 space_at_eol = get(self.ignore_space_at_eol)
723 space_change = get(self.ignore_space_change)
724 all_space = get(self.ignore_all_space)
725 function_context = get(self.function_context)
726 gitcmds.update_diff_overrides(
727 space_at_eol, space_change, all_space, function_context
729 self.widget.update_options()
731 def set_line_numbers(self, value):
732 """Enable / disable line numbers"""
733 self.widget.set_line_numbers(value, update=False)
735 def set_word_wrapping(self, value):
736 """Respond to Qt action callbacks"""
737 self.widget.set_word_wrapping(value, update=False)
739 def hide_advanced_options(self):
740 """Hide advanced options that are not applicable to the DiffWidget"""
741 self.show_line_numbers.setVisible(False)
742 self.ignore_space_at_eol.setVisible(False)
743 self.ignore_space_change.setVisible(False)
744 self.ignore_all_space.setVisible(False)
745 self.function_context.setVisible(False)
748 # pylint: disable=too-many-ancestors
749 class DiffEditor(DiffTextEdit):
751 up = Signal()
752 down = Signal()
753 options_changed = Signal()
755 def __init__(self, context, options, parent):
756 DiffTextEdit.__init__(self, context, parent, numbers=True)
757 self.context = context
758 self.model = model = context.model
759 self.selection_model = selection_model = context.selection
761 # "Diff Options" tool menu
762 self.options = options
764 self.action_apply_selection = qtutils.add_action(
765 self,
766 'Apply',
767 self.apply_selection,
768 hotkeys.STAGE_DIFF,
769 hotkeys.STAGE_DIFF_ALT,
772 self.action_revert_selection = qtutils.add_action(
773 self, 'Revert', self.revert_selection, hotkeys.REVERT
775 self.action_revert_selection.setIcon(icons.undo())
777 self.action_edit_and_apply_selection = qtutils.add_action(
778 self,
779 'Edit and Apply',
780 partial(self.apply_selection, edit=True),
781 hotkeys.EDIT_AND_STAGE_DIFF,
784 self.action_edit_and_revert_selection = qtutils.add_action(
785 self,
786 'Edit and Revert',
787 partial(self.revert_selection, edit=True),
788 hotkeys.EDIT_AND_REVERT,
790 self.action_edit_and_revert_selection.setIcon(icons.undo())
791 self.launch_editor = actions.launch_editor_at_line(
792 context, self, hotkeys.EDIT_SHORT, *hotkeys.ACCEPT
794 self.launch_difftool = actions.launch_difftool(context, self)
795 self.stage_or_unstage = actions.stage_or_unstage(context, self)
797 # Emit up/down signals so that they can be routed by the main widget
798 self.move_up = actions.move_up(self)
799 self.move_down = actions.move_down(self)
801 model.diff_text_updated.connect(self.set_diff, type=Qt.QueuedConnection)
803 selection_model.selection_changed.connect(
804 self.refresh, type=Qt.QueuedConnection
806 # Update the selection model when the cursor changes
807 self.cursorPositionChanged.connect(self._update_line_number)
809 qtutils.connect_button(options.toggle_image_diff, self.toggle_diff_type)
811 def toggle_diff_type(self):
812 cmds.do(cmds.ToggleDiffType, self.context)
814 def refresh(self):
815 enabled = False
816 s = self.selection_model.selection()
817 model = self.model
818 if model.partially_stageable():
819 item = s.modified[0] if s.modified else None
820 if item in model.submodules:
821 pass
822 elif item not in model.unstaged_deleted:
823 enabled = True
824 self.action_revert_selection.setEnabled(enabled)
826 def set_line_numbers(self, enabled, update=False):
827 """Enable/disable the diff line number display"""
828 self.numbers.setVisible(enabled)
829 if update:
830 with qtutils.BlockSignals(self.options.show_line_numbers):
831 self.options.show_line_numbers.setChecked(enabled)
832 # Refresh the display. Not doing this results in the display not
833 # correctly displaying the line numbers widget until the text scrolls.
834 self.set_value(self.value())
836 def update_options(self):
837 self.options_changed.emit()
839 def create_context_menu(self, event_pos):
840 """Override create_context_menu() to display a completely custom menu"""
841 menu = super(DiffEditor, self).create_context_menu(event_pos)
842 context = self.context
843 model = self.model
844 s = self.selection_model.selection()
845 filename = self.selection_model.filename()
847 # These menu actions will be inserted at the start of the widget.
848 current_actions = menu.actions()
849 menu_actions = []
850 add_action = menu_actions.append
852 if model.stageable() or model.unstageable():
853 if model.stageable():
854 self.stage_or_unstage.setText(N_('Stage'))
855 self.stage_or_unstage.setIcon(icons.add())
856 else:
857 self.stage_or_unstage.setText(N_('Unstage'))
858 self.stage_or_unstage.setIcon(icons.remove())
859 add_action(self.stage_or_unstage)
861 if model.partially_stageable():
862 item = s.modified[0] if s.modified else None
863 if item in model.submodules:
864 path = core.abspath(item)
865 action = qtutils.add_action_with_icon(
866 menu,
867 icons.add(),
868 cmds.Stage.name(),
869 cmds.run(cmds.Stage, context, s.modified),
870 hotkeys.STAGE_SELECTION,
872 add_action(action)
874 action = qtutils.add_action_with_icon(
875 menu,
876 icons.cola(),
877 N_('Launch git-cola'),
878 cmds.run(cmds.OpenRepo, context, path),
880 add_action(action)
881 elif item not in model.unstaged_deleted:
882 if self.has_selection():
883 apply_text = N_('Stage Selected Lines')
884 edit_and_apply_text = N_('Edit Selected Lines to Stage...')
885 revert_text = N_('Revert Selected Lines...')
886 edit_and_revert_text = N_('Edit Selected Lines to Revert...')
887 else:
888 apply_text = N_('Stage Diff Hunk')
889 edit_and_apply_text = N_('Edit Diff Hunk to Stage...')
890 revert_text = N_('Revert Diff Hunk...')
891 edit_and_revert_text = N_('Edit Diff Hunk to Revert...')
893 self.action_apply_selection.setText(apply_text)
894 self.action_apply_selection.setIcon(icons.add())
895 add_action(self.action_apply_selection)
897 self.action_edit_and_apply_selection.setText(edit_and_apply_text)
898 self.action_edit_and_apply_selection.setIcon(icons.add())
899 add_action(self.action_edit_and_apply_selection)
901 self.action_revert_selection.setText(revert_text)
902 add_action(self.action_revert_selection)
904 self.action_edit_and_revert_selection.setText(edit_and_revert_text)
905 add_action(self.action_edit_and_revert_selection)
907 if s.staged and model.unstageable():
908 item = s.staged[0]
909 if item in model.submodules:
910 path = core.abspath(item)
911 action = qtutils.add_action_with_icon(
912 menu,
913 icons.remove(),
914 cmds.Unstage.name(),
915 cmds.run(cmds.Unstage, context, s.staged),
916 hotkeys.STAGE_SELECTION,
918 add_action(action)
920 qtutils.add_action_with_icon(
921 menu,
922 icons.cola(),
923 N_('Launch git-cola'),
924 cmds.run(cmds.OpenRepo, context, path),
926 add_action(action)
928 elif item not in model.staged_deleted:
929 if self.has_selection():
930 apply_text = N_('Unstage Selected Lines')
931 edit_and_apply_text = N_('Edit Selected Lines to Unstage...')
932 else:
933 apply_text = N_('Unstage Diff Hunk')
934 edit_and_apply_text = N_('Edit Diff Hunk to Unstage...')
936 self.action_apply_selection.setText(apply_text)
937 self.action_apply_selection.setIcon(icons.remove())
938 add_action(self.action_apply_selection)
940 self.action_edit_and_apply_selection.setText(edit_and_apply_text)
941 self.action_edit_and_apply_selection.setIcon(icons.remove())
942 add_action(self.action_edit_and_apply_selection)
944 if model.stageable() or model.unstageable():
945 # Do not show the "edit" action when the file does not exist.
946 # Untracked files exist by definition.
947 if filename and core.exists(filename):
948 add_action(qtutils.menu_separator(menu))
949 add_action(self.launch_editor)
951 # Removed files can still be diffed.
952 add_action(self.launch_difftool)
954 # Add the Previous/Next File actions, which improves discoverability
955 # of their associated shortcuts
956 add_action(qtutils.menu_separator(menu))
957 add_action(self.move_up)
958 add_action(self.move_down)
959 add_action(qtutils.menu_separator(menu))
961 if current_actions:
962 first_action = current_actions[0]
963 else:
964 first_action = None
965 menu.insertActions(first_action, menu_actions)
967 return menu
969 def mousePressEvent(self, event):
970 if event.button() == Qt.RightButton:
971 # Intercept right-click to move the cursor to the current position.
972 # setTextCursor() clears the selection so this is only done when
973 # nothing is selected.
974 if not self.has_selection():
975 cursor = self.cursorForPosition(event.pos())
976 self.setTextCursor(cursor)
978 return super(DiffEditor, self).mousePressEvent(event)
980 def setPlainText(self, text):
981 """setPlainText(str) while retaining scrollbar positions"""
982 model = self.model
983 mode = model.mode
984 highlight = mode not in (
985 model.mode_none,
986 model.mode_display,
987 model.mode_untracked,
989 self.highlighter.set_enabled(highlight)
991 scrollbar = self.verticalScrollBar()
992 if scrollbar:
993 scrollvalue = get(scrollbar)
994 else:
995 scrollvalue = None
997 if text is None:
998 return
1000 DiffTextEdit.setPlainText(self, text)
1002 if scrollbar and scrollvalue is not None:
1003 scrollbar.setValue(scrollvalue)
1005 def selected_lines(self):
1006 cursor = self.textCursor()
1007 selection_start = cursor.selectionStart()
1008 selection_end = max(selection_start, cursor.selectionEnd() - 1)
1010 first_line_idx = -1
1011 last_line_idx = -1
1012 line_idx = 0
1013 line_start = 0
1015 for line_idx, line in enumerate(get(self).splitlines()):
1016 line_end = line_start + len(line)
1017 if line_start <= selection_start <= line_end:
1018 first_line_idx = line_idx
1019 if line_start <= selection_end <= line_end:
1020 last_line_idx = line_idx
1021 break
1022 line_start = line_end + 1
1024 if first_line_idx == -1:
1025 first_line_idx = line_idx
1027 if last_line_idx == -1:
1028 last_line_idx = line_idx
1030 return first_line_idx, last_line_idx
1032 def selected_text_lines(self):
1033 """Return selected lines and the CRLF / LF separator"""
1034 first_line_idx, last_line_idx = self.selected_lines()
1035 text = get(self)
1036 sep = _get_sep(text)
1037 lines = []
1038 for line_idx, line in enumerate(text.split(sep)):
1039 if first_line_idx <= line_idx <= last_line_idx:
1040 lines.append(line)
1041 return sep, lines
1043 def apply_selection(self, *, edit=False):
1044 model = self.model
1045 s = self.selection_model.single_selection()
1046 if model.partially_stageable() and (s.modified or s.untracked):
1047 self.process_diff_selection(edit=edit)
1048 elif model.unstageable():
1049 self.process_diff_selection(reverse=True, edit=edit)
1051 def revert_selection(self, *, edit=False):
1052 """Destructively revert selected lines or hunk from a worktree file."""
1054 if not edit:
1055 if self.has_selection():
1056 title = N_('Revert Selected Lines?')
1057 ok_text = N_('Revert Selected Lines')
1058 else:
1059 title = N_('Revert Diff Hunk?')
1060 ok_text = N_('Revert Diff Hunk')
1062 if not Interaction.confirm(
1063 title,
1065 'This operation drops uncommitted changes.\n'
1066 'These changes cannot be recovered.'
1068 N_('Revert the uncommitted changes?'),
1069 ok_text,
1070 default=True,
1071 icon=icons.undo(),
1073 return
1074 self.process_diff_selection(reverse=True, apply_to_worktree=True, edit=edit)
1076 def extract_patch(self, reverse=False):
1077 first_line_idx, last_line_idx = self.selected_lines()
1078 patch = diffparse.Patch.parse(self.model.filename, self.model.diff_text)
1079 if self.has_selection():
1080 return patch.extract_subset(first_line_idx, last_line_idx, reverse=reverse)
1081 else:
1082 return patch.extract_hunk(first_line_idx, reverse=reverse)
1084 def patch_encoding(self):
1085 if isinstance(self.model.diff_text, core.UStr):
1086 # original encoding must prevail
1087 return self.model.diff_text.encoding
1088 else:
1089 return self.context.cfg.file_encoding(self.model.filename)
1091 def process_diff_selection(
1092 self, reverse=False, apply_to_worktree=False, edit=False
1094 """Implement un/staging of the selected line(s) or hunk."""
1095 if self.selection_model.is_empty():
1096 return
1097 patch = self.extract_patch(reverse)
1098 if not patch.has_changes():
1099 return
1100 patch_encoding = self.patch_encoding()
1102 if edit:
1103 patch = edit_patch(
1104 patch,
1105 patch_encoding,
1106 self.context,
1107 reverse=reverse,
1108 apply_to_worktree=apply_to_worktree,
1110 if not patch.has_changes():
1111 return
1113 cmds.do(
1114 cmds.ApplyPatch,
1115 self.context,
1116 patch,
1117 patch_encoding,
1118 apply_to_worktree,
1121 def _update_line_number(self):
1122 """Update the selection model when the cursor changes"""
1123 self.selection_model.line_number = self.numbers.current_line()
1126 class DiffWidget(QtWidgets.QWidget):
1127 """Display commit metadata and text diffs"""
1129 def __init__(self, context, parent, is_commit=False, options=None):
1130 QtWidgets.QWidget.__init__(self, parent)
1132 self.context = context
1133 self.oid = 'HEAD'
1134 self.oid_start = None
1135 self.oid_end = None
1136 self.options = options
1138 author_font = QtGui.QFont(self.font())
1139 author_font.setPointSize(int(author_font.pointSize() * 1.1))
1141 summary_font = QtGui.QFont(author_font)
1142 summary_font.setWeight(QtGui.QFont.Bold)
1144 policy = QtWidgets.QSizePolicy(
1145 QtWidgets.QSizePolicy.MinimumExpanding, QtWidgets.QSizePolicy.Minimum
1148 self.gravatar_label = gravatar.GravatarLabel(self.context, parent=self)
1150 self.author_label = TextLabel()
1151 self.author_label.setTextFormat(Qt.RichText)
1152 self.author_label.setFont(author_font)
1153 self.author_label.setSizePolicy(policy)
1154 self.author_label.setAlignment(Qt.AlignBottom)
1155 self.author_label.elide()
1157 self.date_label = TextLabel()
1158 self.date_label.setTextFormat(Qt.PlainText)
1159 self.date_label.setSizePolicy(policy)
1160 self.date_label.setAlignment(Qt.AlignTop)
1161 self.date_label.hide()
1163 self.summary_label = TextLabel()
1164 self.summary_label.setTextFormat(Qt.PlainText)
1165 self.summary_label.setFont(summary_font)
1166 self.summary_label.setSizePolicy(policy)
1167 self.summary_label.setAlignment(Qt.AlignTop)
1168 self.summary_label.elide()
1170 self.oid_label = TextLabel()
1171 self.oid_label.setTextFormat(Qt.PlainText)
1172 self.oid_label.setSizePolicy(policy)
1173 self.oid_label.setAlignment(Qt.AlignTop)
1174 self.oid_label.elide()
1176 self.diff = DiffTextEdit(context, self, is_commit=is_commit, whitespace=False)
1178 self.info_layout = qtutils.vbox(
1179 defs.no_margin,
1180 defs.no_spacing,
1181 self.author_label,
1182 self.date_label,
1183 self.summary_label,
1184 self.oid_label,
1187 self.logo_layout = qtutils.hbox(
1188 defs.no_margin, defs.button_spacing, self.gravatar_label, self.info_layout
1190 self.logo_layout.setContentsMargins(defs.margin, 0, defs.margin, 0)
1192 self.main_layout = qtutils.vbox(
1193 defs.no_margin, defs.spacing, self.logo_layout, self.diff
1195 self.setLayout(self.main_layout)
1197 self.set_tabwidth(prefs.tabwidth(context))
1199 def set_tabwidth(self, width):
1200 self.diff.set_tabwidth(width)
1202 def set_word_wrapping(self, enabled, update=False):
1203 """Enable and disable word wrapping"""
1204 self.diff.set_word_wrapping(enabled, update=update)
1206 def set_options(self, options):
1207 """Register an options widget"""
1208 self.options = options
1209 self.diff.set_options(options)
1211 def start_diff_task(self, task):
1212 """Clear the display and start a diff-gathering task"""
1213 self.diff.save_scrollbar()
1214 self.diff.set_loading_message()
1215 self.context.runtask.start(task, result=self.set_diff)
1217 def set_diff_oid(self, oid, filename=None):
1218 """Set the diff from a single commit object ID"""
1219 task = DiffInfoTask(self.context, oid, filename)
1220 self.start_diff_task(task)
1222 def set_diff_range(self, start, end, filename=None):
1223 task = DiffRangeTask(self.context, start + '~', end, filename)
1224 self.start_diff_task(task)
1226 def commits_selected(self, commits):
1227 """Display an appropriate diff when commits are selected"""
1228 if not commits:
1229 self.clear()
1230 return
1231 commit = commits[0]
1232 oid = commit.oid
1233 email = commit.email or ''
1234 summary = commit.summary or ''
1235 author = commit.author or ''
1236 self.set_details(oid, author, email, '', summary)
1237 self.oid = oid
1239 if len(commits) > 1:
1240 start, end = commits[-1], commits[0]
1241 self.set_diff_range(start.oid, end.oid)
1242 self.oid_start = start
1243 self.oid_end = end
1244 else:
1245 self.set_diff_oid(oid)
1246 self.oid_start = None
1247 self.oid_end = None
1249 def set_diff(self, diff):
1250 """Set the diff text"""
1251 self.diff.set_diff(diff)
1253 def set_details(self, oid, author, email, date, summary):
1254 template_args = {'author': author, 'email': email, 'summary': summary}
1255 author_text = (
1256 """%(author)s &lt;"""
1257 """<a href="mailto:%(email)s">"""
1258 """%(email)s</a>&gt;""" % template_args
1260 author_template = '%(author)s <%(email)s>' % template_args
1262 self.date_label.set_text(date)
1263 self.date_label.setVisible(bool(date))
1264 self.oid_label.set_text(oid)
1265 self.author_label.set_template(author_text, author_template)
1266 self.summary_label.set_text(summary)
1267 self.gravatar_label.set_email(email)
1269 def clear(self):
1270 self.date_label.set_text('')
1271 self.oid_label.set_text('')
1272 self.author_label.set_text('')
1273 self.summary_label.set_text('')
1274 self.gravatar_label.clear()
1275 self.diff.clear()
1277 def files_selected(self, filenames):
1278 """Update the view when a filename is selected"""
1279 if not filenames:
1280 return
1281 oid_start = self.oid_start
1282 oid_end = self.oid_end
1283 if oid_start and oid_end:
1284 self.set_diff_range(oid_start.oid, oid_end.oid, filename=filenames[0])
1285 else:
1286 self.set_diff_oid(self.oid, filename=filenames[0])
1289 class TextLabel(QtWidgets.QLabel):
1290 def __init__(self, parent=None):
1291 QtWidgets.QLabel.__init__(self, parent)
1292 self.setTextInteractionFlags(
1293 Qt.TextSelectableByMouse | Qt.LinksAccessibleByMouse
1295 self._display = ''
1296 self._template = ''
1297 self._text = ''
1298 self._elide = False
1299 self._metrics = QtGui.QFontMetrics(self.font())
1300 self.setOpenExternalLinks(True)
1302 def elide(self):
1303 self._elide = True
1305 def set_text(self, text):
1306 self.set_template(text, text)
1308 def set_template(self, text, template):
1309 self._display = text
1310 self._text = text
1311 self._template = template
1312 self.update_text(self.width())
1313 self.setText(self._display)
1315 def update_text(self, width):
1316 self._display = self._text
1317 if not self._elide:
1318 return
1319 text = self._metrics.elidedText(self._template, Qt.ElideRight, width - 2)
1320 if text != self._template:
1321 self._display = text
1323 # Qt overrides
1324 def setFont(self, font):
1325 self._metrics = QtGui.QFontMetrics(font)
1326 QtWidgets.QLabel.setFont(self, font)
1328 def resizeEvent(self, event):
1329 if self._elide:
1330 self.update_text(event.size().width())
1331 with qtutils.BlockSignals(self):
1332 self.setText(self._display)
1333 QtWidgets.QLabel.resizeEvent(self, event)
1336 class DiffInfoTask(qtutils.Task):
1337 """Gather diffs for a single commit"""
1338 def __init__(self, context, oid, filename):
1339 qtutils.Task.__init__(self)
1340 self.context = context
1341 self.oid = oid
1342 self.filename = filename
1344 def task(self):
1345 context = self.context
1346 oid = self.oid
1347 return gitcmds.diff_info(context, oid, filename=self.filename)
1350 class DiffRangeTask(qtutils.Task):
1351 """Gather diffs for a range of commits"""
1352 def __init__(self, context, start, end, filename):
1353 qtutils.Task.__init__(self)
1354 self.context = context
1355 self.start = start
1356 self.end = end
1357 self.filename = filename
1359 def task(self):
1360 context = self.context
1361 return gitcmds.diff_range(context, self.start, self.end, filename=self.filename)