diff: move selected_lines() and selected_text() to DiffTextEdit
[git-cola.git] / cola / widgets / diff.py
blob2010f83022865bce5ce88c1c46ba260f8d366594
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)
261 def selected_lines(self):
262 """Return selected lines"""
263 cursor = self.textCursor()
264 selection_start = cursor.selectionStart()
265 selection_end = max(selection_start, cursor.selectionEnd() - 1)
267 first_line_idx = -1
268 last_line_idx = -1
269 line_idx = 0
270 line_start = 0
272 for line_idx, line in enumerate(get(self, default='').splitlines()):
273 line_end = line_start + len(line)
274 if line_start <= selection_start <= line_end:
275 first_line_idx = line_idx
276 if line_start <= selection_end <= line_end:
277 last_line_idx = line_idx
278 break
279 line_start = line_end + 1
281 if first_line_idx == -1:
282 first_line_idx = line_idx
284 if last_line_idx == -1:
285 last_line_idx = line_idx
287 return first_line_idx, last_line_idx
289 def selected_text_lines(self):
290 """Return selected lines and the CRLF / LF separator"""
291 first_line_idx, last_line_idx = self.selected_lines()
292 text = get(self, default='')
293 sep = _get_sep(text)
294 lines = []
295 for line_idx, line in enumerate(text.split(sep)):
296 if first_line_idx <= line_idx <= last_line_idx:
297 lines.append(line)
298 return sep, lines
301 def _get_sep(text):
302 """Return either CRLF or LF based on the content"""
303 if '\r\n' in text:
304 sep = '\r\n'
305 else:
306 sep = '\n'
307 return sep
310 def _strip_diff(value):
311 """Remove +/-/<space> from a selection"""
312 if value.startswith(('+', '-', ' ')):
313 return value[1:]
314 return value
317 class DiffLineNumbers(TextDecorator):
318 def __init__(self, context, parent):
319 TextDecorator.__init__(self, parent)
320 self.highlight_line = -1
321 self.lines = None
322 self.parser = diffparse.DiffLines()
323 self.formatter = diffparse.FormatDigits()
325 self.setFont(qtutils.diff_font(context))
326 self._char_width = self.fontMetrics().width('0')
328 QPalette = QtGui.QPalette
329 self._palette = palette = self.palette()
330 self._base = palette.color(QtGui.QPalette.Base)
331 self._highlight = palette.color(QPalette.Highlight)
332 self._highlight.setAlphaF(0.3)
333 self._highlight_text = palette.color(QPalette.HighlightedText)
334 self._window = palette.color(QPalette.Window)
335 self._disabled = palette.color(QPalette.Disabled, QPalette.Text)
337 def set_diff(self, diff):
338 self.lines = self.parser.parse(diff)
339 self.formatter.set_digits(self.parser.digits())
341 def width_hint(self):
342 if not self.isVisible():
343 return 0
344 parser = self.parser
346 if parser.merge:
347 columns = 3
348 extra = 3 # one space in-between, one space after
349 else:
350 columns = 2
351 extra = 2 # one space in-between, one space after
353 digits = parser.digits() * columns
355 return defs.margin + (self._char_width * (digits + extra))
357 def set_highlighted(self, line_number):
358 """Set the line to highlight"""
359 self.highlight_line = line_number
361 def current_line(self):
362 lines = self.lines
363 if lines and self.highlight_line >= 0:
364 # Find the next valid line
365 for i in range(self.highlight_line, len(lines)):
366 # take the "new" line number: last value in tuple
367 line_number = lines[i][-1]
368 if line_number > 0:
369 return line_number
371 # Find the previous valid line
372 for i in range(self.highlight_line - 1, -1, -1):
373 # take the "new" line number: last value in tuple
374 if i < len(lines):
375 line_number = lines[i][-1]
376 if line_number > 0:
377 return line_number
378 return None
380 def paintEvent(self, event):
381 """Paint the line number"""
382 if not self.lines:
383 return
385 painter = QtGui.QPainter(self)
386 painter.fillRect(event.rect(), self._base)
388 editor = self.editor
389 content_offset = editor.contentOffset()
390 block = editor.firstVisibleBlock()
391 width = self.width()
392 text_width = width - (defs.margin * 2)
393 text_flags = Qt.AlignRight | Qt.AlignVCenter
394 event_rect_bottom = event.rect().bottom()
396 highlight_line = self.highlight_line
397 highlight = self._highlight
398 highlight_text = self._highlight_text
399 disabled = self._disabled
401 fmt = self.formatter
402 lines = self.lines
403 num_lines = len(lines)
405 while block.isValid():
406 block_number = block.blockNumber()
407 if block_number >= num_lines:
408 break
409 block_geom = editor.blockBoundingGeometry(block)
410 rect = block_geom.translated(content_offset).toRect()
411 if not block.isVisible() or rect.top() >= event_rect_bottom:
412 break
414 if block_number == highlight_line:
415 painter.fillRect(rect.x(), rect.y(), width, rect.height(), highlight)
416 painter.setPen(highlight_text)
417 else:
418 painter.setPen(disabled)
420 line = lines[block_number]
421 if len(line) == 2:
422 a, b = line
423 text = fmt.value(a, b)
424 elif len(line) == 3:
425 old, base, new = line
426 text = fmt.merge_value(old, base, new)
428 painter.drawText(
429 rect.x(),
430 rect.y(),
431 text_width,
432 rect.height(),
433 text_flags,
434 text,
437 block = block.next() # pylint: disable=next-method-called
440 class Viewer(QtWidgets.QFrame):
441 """Text and image diff viewers"""
443 def __init__(self, context, parent=None):
444 super(Viewer, self).__init__(parent)
446 self.context = context
447 self.model = model = context.model
448 self.images = []
449 self.pixmaps = []
450 self.options = options = Options(self)
451 self.text = DiffEditor(context, options, self)
452 self.image = imageview.ImageView(parent=self)
453 self.image.setFocusPolicy(Qt.NoFocus)
455 stack = self.stack = QtWidgets.QStackedWidget(self)
456 stack.addWidget(self.text)
457 stack.addWidget(self.image)
459 self.main_layout = qtutils.vbox(defs.no_margin, defs.no_spacing, self.stack)
460 self.setLayout(self.main_layout)
462 # Observe images
463 model.images_changed.connect(self.set_images, type=Qt.QueuedConnection)
465 # Observe the diff type
466 model.diff_type_changed.connect(self.set_diff_type, type=Qt.QueuedConnection)
468 # Observe the file type
469 model.file_type_changed.connect(self.set_file_type, type=Qt.QueuedConnection)
471 # Observe the image mode combo box
472 options.image_mode.currentIndexChanged.connect(lambda _: self.render())
473 options.zoom_mode.currentIndexChanged.connect(lambda _: self.render())
475 self.setFocusProxy(self.text)
477 def export_state(self, state):
478 state['show_diff_line_numbers'] = self.options.show_line_numbers.isChecked()
479 state['image_diff_mode'] = self.options.image_mode.currentIndex()
480 state['image_zoom_mode'] = self.options.zoom_mode.currentIndex()
481 state['word_wrap'] = self.options.enable_word_wrapping.isChecked()
482 return state
484 def apply_state(self, state):
485 diff_numbers = bool(state.get('show_diff_line_numbers', False))
486 self.set_line_numbers(diff_numbers, update=True)
488 image_mode = utils.asint(state.get('image_diff_mode', 0))
489 self.options.image_mode.set_index(image_mode)
491 zoom_mode = utils.asint(state.get('image_zoom_mode', 0))
492 self.options.zoom_mode.set_index(zoom_mode)
494 word_wrap = bool(state.get('word_wrap', True))
495 self.set_word_wrapping(word_wrap, update=True)
496 return True
498 def set_diff_type(self, diff_type):
499 """Manage the image and text diff views when selection changes"""
500 # The "diff type" is whether the diff viewer is displaying an image.
501 self.options.set_diff_type(diff_type)
502 if diff_type == main.Types.IMAGE:
503 self.stack.setCurrentWidget(self.image)
504 self.render()
505 else:
506 self.stack.setCurrentWidget(self.text)
508 def set_file_type(self, file_type):
509 """Manage the diff options when the file type changes"""
510 # The "file type" is whether the file itself is an image.
511 self.options.set_file_type(file_type)
513 def update_options(self):
514 """Emit a signal indicating that options have changed"""
515 self.text.update_options()
517 def set_line_numbers(self, enabled, update=False):
518 """Enable/disable line numbers in the text widget"""
519 self.text.set_line_numbers(enabled, update=update)
521 def set_word_wrapping(self, enabled, update=False):
522 """Enable/disable word wrapping in the text widget"""
523 self.text.set_word_wrapping(enabled, update=update)
525 def reset(self):
526 self.image.pixmap = QtGui.QPixmap()
527 self.cleanup()
529 def cleanup(self):
530 for (image, unlink) in self.images:
531 if unlink and core.exists(image):
532 os.unlink(image)
533 self.images = []
535 def set_images(self, images):
536 self.images = images
537 self.pixmaps = []
538 if not images:
539 self.reset()
540 return False
542 # In order to comp, we first have to load all the images
543 all_pixmaps = [QtGui.QPixmap(image[0]) for image in images]
544 pixmaps = [pixmap for pixmap in all_pixmaps if not pixmap.isNull()]
545 if not pixmaps:
546 self.reset()
547 return False
549 self.pixmaps = pixmaps
550 self.render()
551 self.cleanup()
552 return True
554 def render(self):
555 # Update images
556 if self.pixmaps:
557 mode = self.options.image_mode.currentIndex()
558 if mode == self.options.SIDE_BY_SIDE:
559 image = self.render_side_by_side()
560 elif mode == self.options.DIFF:
561 image = self.render_diff()
562 elif mode == self.options.XOR:
563 image = self.render_xor()
564 elif mode == self.options.PIXEL_XOR:
565 image = self.render_pixel_xor()
566 else:
567 image = self.render_side_by_side()
568 else:
569 image = QtGui.QPixmap()
570 self.image.pixmap = image
572 # Apply zoom
573 zoom_mode = self.options.zoom_mode.currentIndex()
574 zoom_factor = self.options.zoom_factors[zoom_mode][1]
575 if zoom_factor > 0.0:
576 self.image.resetTransform()
577 self.image.scale(zoom_factor, zoom_factor)
578 poly = self.image.mapToScene(self.image.viewport().rect())
579 self.image.last_scene_roi = poly.boundingRect()
581 def render_side_by_side(self):
582 # Side-by-side lineup comp
583 pixmaps = self.pixmaps
584 width = sum(pixmap.width() for pixmap in pixmaps)
585 height = max(pixmap.height() for pixmap in pixmaps)
586 image = create_image(width, height)
588 # Paint each pixmap
589 painter = create_painter(image)
590 x = 0
591 for pixmap in pixmaps:
592 painter.drawPixmap(x, 0, pixmap)
593 x += pixmap.width()
594 painter.end()
596 return image
598 def render_comp(self, comp_mode):
599 # Get the max size to use as the render canvas
600 pixmaps = self.pixmaps
601 if len(pixmaps) == 1:
602 return pixmaps[0]
604 width = max(pixmap.width() for pixmap in pixmaps)
605 height = max(pixmap.height() for pixmap in pixmaps)
606 image = create_image(width, height)
608 painter = create_painter(image)
609 for pixmap in (pixmaps[0], pixmaps[-1]):
610 x = (width - pixmap.width()) // 2
611 y = (height - pixmap.height()) // 2
612 painter.drawPixmap(x, y, pixmap)
613 painter.setCompositionMode(comp_mode)
614 painter.end()
616 return image
618 def render_diff(self):
619 comp_mode = QtGui.QPainter.CompositionMode_Difference
620 return self.render_comp(comp_mode)
622 def render_xor(self):
623 comp_mode = QtGui.QPainter.CompositionMode_Xor
624 return self.render_comp(comp_mode)
626 def render_pixel_xor(self):
627 comp_mode = QtGui.QPainter.RasterOp_SourceXorDestination
628 return self.render_comp(comp_mode)
631 def create_image(width, height):
632 size = QtCore.QSize(width, height)
633 image = QtGui.QImage(size, QtGui.QImage.Format_ARGB32_Premultiplied)
634 image.fill(Qt.transparent)
635 return image
638 def create_painter(image):
639 painter = QtGui.QPainter(image)
640 painter.fillRect(image.rect(), Qt.transparent)
641 return painter
644 class Options(QtWidgets.QWidget):
645 """Provide the options widget used by the editor
647 Actions are registered on the parent widget.
651 # mode combobox indexes
652 SIDE_BY_SIDE = 0
653 DIFF = 1
654 XOR = 2
655 PIXEL_XOR = 3
657 def __init__(self, parent):
658 super(Options, self).__init__(parent)
659 # Create widgets
660 self.widget = parent
661 self.ignore_space_at_eol = self.add_option(
662 N_('Ignore changes in whitespace at EOL')
665 self.ignore_space_change = self.add_option(
666 N_('Ignore changes in amount of whitespace')
669 self.ignore_all_space = self.add_option(N_('Ignore all whitespace'))
671 self.function_context = self.add_option(
672 N_('Show whole surrounding functions of changes')
675 self.show_line_numbers = qtutils.add_action_bool(
676 self, N_('Show line numbers'), self.set_line_numbers, True
678 self.enable_word_wrapping = qtutils.add_action_bool(
679 self, N_('Enable word wrapping'), self.set_word_wrapping, True
682 self.options = qtutils.create_action_button(
683 tooltip=N_('Diff Options'), icon=icons.configure()
686 self.toggle_image_diff = qtutils.create_action_button(
687 tooltip=N_('Toggle image diff'), icon=icons.visualize()
689 self.toggle_image_diff.hide()
691 self.image_mode = qtutils.combo(
692 [N_('Side by side'), N_('Diff'), N_('XOR'), N_('Pixel XOR')]
695 self.zoom_factors = (
696 (N_('Zoom to Fit'), 0.0),
697 (N_('25%'), 0.25),
698 (N_('50%'), 0.5),
699 (N_('100%'), 1.0),
700 (N_('200%'), 2.0),
701 (N_('400%'), 4.0),
702 (N_('800%'), 8.0),
704 zoom_modes = [factor[0] for factor in self.zoom_factors]
705 self.zoom_mode = qtutils.combo(zoom_modes, parent=self)
707 self.menu = menu = qtutils.create_menu(N_('Diff Options'), self.options)
708 self.options.setMenu(menu)
709 menu.addAction(self.ignore_space_at_eol)
710 menu.addAction(self.ignore_space_change)
711 menu.addAction(self.ignore_all_space)
712 menu.addSeparator()
713 menu.addAction(self.function_context)
714 menu.addAction(self.show_line_numbers)
715 menu.addSeparator()
716 menu.addAction(self.enable_word_wrapping)
718 # Layouts
719 layout = qtutils.hbox(
720 defs.no_margin,
721 defs.button_spacing,
722 self.image_mode,
723 self.zoom_mode,
724 self.options,
725 self.toggle_image_diff,
727 self.setLayout(layout)
729 # Policies
730 self.image_mode.setFocusPolicy(Qt.NoFocus)
731 self.zoom_mode.setFocusPolicy(Qt.NoFocus)
732 self.options.setFocusPolicy(Qt.NoFocus)
733 self.toggle_image_diff.setFocusPolicy(Qt.NoFocus)
734 self.setFocusPolicy(Qt.NoFocus)
736 def set_file_type(self, file_type):
737 """Set whether we are viewing an image file type"""
738 is_image = file_type == main.Types.IMAGE
739 self.toggle_image_diff.setVisible(is_image)
741 def set_diff_type(self, diff_type):
742 """Toggle between image and text diffs"""
743 is_text = diff_type == main.Types.TEXT
744 is_image = diff_type == main.Types.IMAGE
745 self.options.setVisible(is_text)
746 self.image_mode.setVisible(is_image)
747 self.zoom_mode.setVisible(is_image)
748 if is_image:
749 self.toggle_image_diff.setIcon(icons.diff())
750 else:
751 self.toggle_image_diff.setIcon(icons.visualize())
753 def add_option(self, title):
754 """Add a diff option which calls update_options() on change"""
755 action = qtutils.add_action(self, title, self.update_options)
756 action.setCheckable(True)
757 return action
759 def update_options(self):
760 """Update diff options in response to UI events"""
761 space_at_eol = get(self.ignore_space_at_eol)
762 space_change = get(self.ignore_space_change)
763 all_space = get(self.ignore_all_space)
764 function_context = get(self.function_context)
765 gitcmds.update_diff_overrides(
766 space_at_eol, space_change, all_space, function_context
768 self.widget.update_options()
770 def set_line_numbers(self, value):
771 """Enable / disable line numbers"""
772 self.widget.set_line_numbers(value, update=False)
774 def set_word_wrapping(self, value):
775 """Respond to Qt action callbacks"""
776 self.widget.set_word_wrapping(value, update=False)
778 def hide_advanced_options(self):
779 """Hide advanced options that are not applicable to the DiffWidget"""
780 self.show_line_numbers.setVisible(False)
781 self.ignore_space_at_eol.setVisible(False)
782 self.ignore_space_change.setVisible(False)
783 self.ignore_all_space.setVisible(False)
784 self.function_context.setVisible(False)
787 # pylint: disable=too-many-ancestors
788 class DiffEditor(DiffTextEdit):
790 up = Signal()
791 down = Signal()
792 options_changed = Signal()
794 def __init__(self, context, options, parent):
795 DiffTextEdit.__init__(self, context, parent, numbers=True)
796 self.context = context
797 self.model = model = context.model
798 self.selection_model = selection_model = context.selection
800 # "Diff Options" tool menu
801 self.options = options
803 self.action_apply_selection = qtutils.add_action(
804 self,
805 'Apply',
806 self.apply_selection,
807 hotkeys.STAGE_DIFF,
808 hotkeys.STAGE_DIFF_ALT,
811 self.action_revert_selection = qtutils.add_action(
812 self, 'Revert', self.revert_selection, hotkeys.REVERT
814 self.action_revert_selection.setIcon(icons.undo())
816 self.action_edit_and_apply_selection = qtutils.add_action(
817 self,
818 'Edit and Apply',
819 partial(self.apply_selection, edit=True),
820 hotkeys.EDIT_AND_STAGE_DIFF,
823 self.action_edit_and_revert_selection = qtutils.add_action(
824 self,
825 'Edit and Revert',
826 partial(self.revert_selection, edit=True),
827 hotkeys.EDIT_AND_REVERT,
829 self.action_edit_and_revert_selection.setIcon(icons.undo())
830 self.launch_editor = actions.launch_editor_at_line(
831 context, self, hotkeys.EDIT_SHORT, *hotkeys.ACCEPT
833 self.launch_difftool = actions.launch_difftool(context, self)
834 self.stage_or_unstage = actions.stage_or_unstage(context, self)
836 # Emit up/down signals so that they can be routed by the main widget
837 self.move_up = actions.move_up(self)
838 self.move_down = actions.move_down(self)
840 model.diff_text_updated.connect(self.set_diff, type=Qt.QueuedConnection)
842 selection_model.selection_changed.connect(
843 self.refresh, type=Qt.QueuedConnection
845 # Update the selection model when the cursor changes
846 self.cursorPositionChanged.connect(self._update_line_number)
848 qtutils.connect_button(options.toggle_image_diff, self.toggle_diff_type)
850 def toggle_diff_type(self):
851 cmds.do(cmds.ToggleDiffType, self.context)
853 def refresh(self):
854 enabled = False
855 s = self.selection_model.selection()
856 model = self.model
857 if model.partially_stageable():
858 item = s.modified[0] if s.modified else None
859 if item in model.submodules:
860 pass
861 elif item not in model.unstaged_deleted:
862 enabled = True
863 self.action_revert_selection.setEnabled(enabled)
865 def set_line_numbers(self, enabled, update=False):
866 """Enable/disable the diff line number display"""
867 self.numbers.setVisible(enabled)
868 if update:
869 with qtutils.BlockSignals(self.options.show_line_numbers):
870 self.options.show_line_numbers.setChecked(enabled)
871 # Refresh the display. Not doing this results in the display not
872 # correctly displaying the line numbers widget until the text scrolls.
873 self.set_value(self.value())
875 def update_options(self):
876 self.options_changed.emit()
878 def create_context_menu(self, event_pos):
879 """Override create_context_menu() to display a completely custom menu"""
880 menu = super(DiffEditor, self).create_context_menu(event_pos)
881 context = self.context
882 model = self.model
883 s = self.selection_model.selection()
884 filename = self.selection_model.filename()
886 # These menu actions will be inserted at the start of the widget.
887 current_actions = menu.actions()
888 menu_actions = []
889 add_action = menu_actions.append
891 if model.stageable() or model.unstageable():
892 if model.stageable():
893 self.stage_or_unstage.setText(N_('Stage'))
894 self.stage_or_unstage.setIcon(icons.add())
895 else:
896 self.stage_or_unstage.setText(N_('Unstage'))
897 self.stage_or_unstage.setIcon(icons.remove())
898 add_action(self.stage_or_unstage)
900 if model.partially_stageable():
901 item = s.modified[0] if s.modified else None
902 if item in model.submodules:
903 path = core.abspath(item)
904 action = qtutils.add_action_with_icon(
905 menu,
906 icons.add(),
907 cmds.Stage.name(),
908 cmds.run(cmds.Stage, context, s.modified),
909 hotkeys.STAGE_SELECTION,
911 add_action(action)
913 action = qtutils.add_action_with_icon(
914 menu,
915 icons.cola(),
916 N_('Launch git-cola'),
917 cmds.run(cmds.OpenRepo, context, path),
919 add_action(action)
920 elif item not in model.unstaged_deleted:
921 if self.has_selection():
922 apply_text = N_('Stage Selected Lines')
923 edit_and_apply_text = N_('Edit Selected Lines to Stage...')
924 revert_text = N_('Revert Selected Lines...')
925 edit_and_revert_text = N_('Edit Selected Lines to Revert...')
926 else:
927 apply_text = N_('Stage Diff Hunk')
928 edit_and_apply_text = N_('Edit Diff Hunk to Stage...')
929 revert_text = N_('Revert Diff Hunk...')
930 edit_and_revert_text = N_('Edit Diff Hunk to Revert...')
932 self.action_apply_selection.setText(apply_text)
933 self.action_apply_selection.setIcon(icons.add())
934 add_action(self.action_apply_selection)
936 self.action_edit_and_apply_selection.setText(edit_and_apply_text)
937 self.action_edit_and_apply_selection.setIcon(icons.add())
938 add_action(self.action_edit_and_apply_selection)
940 self.action_revert_selection.setText(revert_text)
941 add_action(self.action_revert_selection)
943 self.action_edit_and_revert_selection.setText(edit_and_revert_text)
944 add_action(self.action_edit_and_revert_selection)
946 if s.staged and model.unstageable():
947 item = s.staged[0]
948 if item in model.submodules:
949 path = core.abspath(item)
950 action = qtutils.add_action_with_icon(
951 menu,
952 icons.remove(),
953 cmds.Unstage.name(),
954 cmds.run(cmds.Unstage, context, s.staged),
955 hotkeys.STAGE_SELECTION,
957 add_action(action)
959 qtutils.add_action_with_icon(
960 menu,
961 icons.cola(),
962 N_('Launch git-cola'),
963 cmds.run(cmds.OpenRepo, context, path),
965 add_action(action)
967 elif item not in model.staged_deleted:
968 if self.has_selection():
969 apply_text = N_('Unstage Selected Lines')
970 edit_and_apply_text = N_('Edit Selected Lines to Unstage...')
971 else:
972 apply_text = N_('Unstage Diff Hunk')
973 edit_and_apply_text = N_('Edit Diff Hunk to Unstage...')
975 self.action_apply_selection.setText(apply_text)
976 self.action_apply_selection.setIcon(icons.remove())
977 add_action(self.action_apply_selection)
979 self.action_edit_and_apply_selection.setText(edit_and_apply_text)
980 self.action_edit_and_apply_selection.setIcon(icons.remove())
981 add_action(self.action_edit_and_apply_selection)
983 if model.stageable() or model.unstageable():
984 # Do not show the "edit" action when the file does not exist.
985 # Untracked files exist by definition.
986 if filename and core.exists(filename):
987 add_action(qtutils.menu_separator(menu))
988 add_action(self.launch_editor)
990 # Removed files can still be diffed.
991 add_action(self.launch_difftool)
993 # Add the Previous/Next File actions, which improves discoverability
994 # of their associated shortcuts
995 add_action(qtutils.menu_separator(menu))
996 add_action(self.move_up)
997 add_action(self.move_down)
998 add_action(qtutils.menu_separator(menu))
1000 if current_actions:
1001 first_action = current_actions[0]
1002 else:
1003 first_action = None
1004 menu.insertActions(first_action, menu_actions)
1006 return menu
1008 def mousePressEvent(self, event):
1009 if event.button() == Qt.RightButton:
1010 # Intercept right-click to move the cursor to the current position.
1011 # setTextCursor() clears the selection so this is only done when
1012 # nothing is selected.
1013 if not self.has_selection():
1014 cursor = self.cursorForPosition(event.pos())
1015 self.setTextCursor(cursor)
1017 return super(DiffEditor, self).mousePressEvent(event)
1019 def setPlainText(self, text):
1020 """setPlainText(str) while retaining scrollbar positions"""
1021 model = self.model
1022 mode = model.mode
1023 highlight = mode not in (
1024 model.mode_none,
1025 model.mode_display,
1026 model.mode_untracked,
1028 self.highlighter.set_enabled(highlight)
1030 scrollbar = self.verticalScrollBar()
1031 if scrollbar:
1032 scrollvalue = get(scrollbar)
1033 else:
1034 scrollvalue = None
1036 if text is None:
1037 return
1039 DiffTextEdit.setPlainText(self, text)
1041 if scrollbar and scrollvalue is not None:
1042 scrollbar.setValue(scrollvalue)
1044 def apply_selection(self, *, edit=False):
1045 model = self.model
1046 s = self.selection_model.single_selection()
1047 if model.partially_stageable() and (s.modified or s.untracked):
1048 self.process_diff_selection(edit=edit)
1049 elif model.unstageable():
1050 self.process_diff_selection(reverse=True, edit=edit)
1052 def revert_selection(self, *, edit=False):
1053 """Destructively revert selected lines or hunk from a worktree file."""
1055 if not edit:
1056 if self.has_selection():
1057 title = N_('Revert Selected Lines?')
1058 ok_text = N_('Revert Selected Lines')
1059 else:
1060 title = N_('Revert Diff Hunk?')
1061 ok_text = N_('Revert Diff Hunk')
1063 if not Interaction.confirm(
1064 title,
1066 'This operation drops uncommitted changes.\n'
1067 'These changes cannot be recovered.'
1069 N_('Revert the uncommitted changes?'),
1070 ok_text,
1071 default=True,
1072 icon=icons.undo(),
1074 return
1075 self.process_diff_selection(reverse=True, apply_to_worktree=True, edit=edit)
1077 def extract_patch(self, reverse=False):
1078 first_line_idx, last_line_idx = self.selected_lines()
1079 patch = diffparse.Patch.parse(self.model.filename, self.model.diff_text)
1080 if self.has_selection():
1081 return patch.extract_subset(first_line_idx, last_line_idx, reverse=reverse)
1082 else:
1083 return patch.extract_hunk(first_line_idx, reverse=reverse)
1085 def patch_encoding(self):
1086 if isinstance(self.model.diff_text, core.UStr):
1087 # original encoding must prevail
1088 return self.model.diff_text.encoding
1089 else:
1090 return self.context.cfg.file_encoding(self.model.filename)
1092 def process_diff_selection(
1093 self, reverse=False, apply_to_worktree=False, edit=False
1095 """Implement un/staging of the selected line(s) or hunk."""
1096 if self.selection_model.is_empty():
1097 return
1098 patch = self.extract_patch(reverse)
1099 if not patch.has_changes():
1100 return
1101 patch_encoding = self.patch_encoding()
1103 if edit:
1104 patch = edit_patch(
1105 patch,
1106 patch_encoding,
1107 self.context,
1108 reverse=reverse,
1109 apply_to_worktree=apply_to_worktree,
1111 if not patch.has_changes():
1112 return
1114 cmds.do(
1115 cmds.ApplyPatch,
1116 self.context,
1117 patch,
1118 patch_encoding,
1119 apply_to_worktree,
1122 def _update_line_number(self):
1123 """Update the selection model when the cursor changes"""
1124 self.selection_model.line_number = self.numbers.current_line()
1127 class DiffWidget(QtWidgets.QWidget):
1128 """Display commit metadata and text diffs"""
1130 def __init__(self, context, parent, is_commit=False, options=None):
1131 QtWidgets.QWidget.__init__(self, parent)
1133 self.context = context
1134 self.oid = 'HEAD'
1135 self.oid_start = None
1136 self.oid_end = None
1137 self.options = options
1139 author_font = QtGui.QFont(self.font())
1140 author_font.setPointSize(int(author_font.pointSize() * 1.1))
1142 summary_font = QtGui.QFont(author_font)
1143 summary_font.setWeight(QtGui.QFont.Bold)
1145 policy = QtWidgets.QSizePolicy(
1146 QtWidgets.QSizePolicy.MinimumExpanding, QtWidgets.QSizePolicy.Minimum
1149 self.gravatar_label = gravatar.GravatarLabel(self.context, parent=self)
1151 self.author_label = TextLabel()
1152 self.author_label.setTextFormat(Qt.RichText)
1153 self.author_label.setFont(author_font)
1154 self.author_label.setSizePolicy(policy)
1155 self.author_label.setAlignment(Qt.AlignBottom)
1156 self.author_label.elide()
1158 self.date_label = TextLabel()
1159 self.date_label.setTextFormat(Qt.PlainText)
1160 self.date_label.setSizePolicy(policy)
1161 self.date_label.setAlignment(Qt.AlignTop)
1162 self.date_label.hide()
1164 self.summary_label = TextLabel()
1165 self.summary_label.setTextFormat(Qt.PlainText)
1166 self.summary_label.setFont(summary_font)
1167 self.summary_label.setSizePolicy(policy)
1168 self.summary_label.setAlignment(Qt.AlignTop)
1169 self.summary_label.elide()
1171 self.oid_label = TextLabel()
1172 self.oid_label.setTextFormat(Qt.PlainText)
1173 self.oid_label.setSizePolicy(policy)
1174 self.oid_label.setAlignment(Qt.AlignTop)
1175 self.oid_label.elide()
1177 self.diff = DiffTextEdit(context, self, is_commit=is_commit, whitespace=False)
1179 self.info_layout = qtutils.vbox(
1180 defs.no_margin,
1181 defs.no_spacing,
1182 self.author_label,
1183 self.date_label,
1184 self.summary_label,
1185 self.oid_label,
1188 self.logo_layout = qtutils.hbox(
1189 defs.no_margin, defs.button_spacing, self.gravatar_label, self.info_layout
1191 self.logo_layout.setContentsMargins(defs.margin, 0, defs.margin, 0)
1193 self.main_layout = qtutils.vbox(
1194 defs.no_margin, defs.spacing, self.logo_layout, self.diff
1196 self.setLayout(self.main_layout)
1198 self.set_tabwidth(prefs.tabwidth(context))
1200 def set_tabwidth(self, width):
1201 self.diff.set_tabwidth(width)
1203 def set_word_wrapping(self, enabled, update=False):
1204 """Enable and disable word wrapping"""
1205 self.diff.set_word_wrapping(enabled, update=update)
1207 def set_options(self, options):
1208 """Register an options widget"""
1209 self.options = options
1210 self.diff.set_options(options)
1212 def start_diff_task(self, task):
1213 """Clear the display and start a diff-gathering task"""
1214 self.diff.save_scrollbar()
1215 self.diff.set_loading_message()
1216 self.context.runtask.start(task, result=self.set_diff)
1218 def set_diff_oid(self, oid, filename=None):
1219 """Set the diff from a single commit object ID"""
1220 task = DiffInfoTask(self.context, oid, filename)
1221 self.start_diff_task(task)
1223 def set_diff_range(self, start, end, filename=None):
1224 task = DiffRangeTask(self.context, start + '~', end, filename)
1225 self.start_diff_task(task)
1227 def commits_selected(self, commits):
1228 """Display an appropriate diff when commits are selected"""
1229 if not commits:
1230 self.clear()
1231 return
1232 commit = commits[0]
1233 oid = commit.oid
1234 email = commit.email or ''
1235 summary = commit.summary or ''
1236 author = commit.author or ''
1237 self.set_details(oid, author, email, '', summary)
1238 self.oid = oid
1240 if len(commits) > 1:
1241 start, end = commits[-1], commits[0]
1242 self.set_diff_range(start.oid, end.oid)
1243 self.oid_start = start
1244 self.oid_end = end
1245 else:
1246 self.set_diff_oid(oid)
1247 self.oid_start = None
1248 self.oid_end = None
1250 def set_diff(self, diff):
1251 """Set the diff text"""
1252 self.diff.set_diff(diff)
1254 def set_details(self, oid, author, email, date, summary):
1255 template_args = {'author': author, 'email': email, 'summary': summary}
1256 author_text = (
1257 """%(author)s &lt;"""
1258 """<a href="mailto:%(email)s">"""
1259 """%(email)s</a>&gt;""" % template_args
1261 author_template = '%(author)s <%(email)s>' % template_args
1263 self.date_label.set_text(date)
1264 self.date_label.setVisible(bool(date))
1265 self.oid_label.set_text(oid)
1266 self.author_label.set_template(author_text, author_template)
1267 self.summary_label.set_text(summary)
1268 self.gravatar_label.set_email(email)
1270 def clear(self):
1271 self.date_label.set_text('')
1272 self.oid_label.set_text('')
1273 self.author_label.set_text('')
1274 self.summary_label.set_text('')
1275 self.gravatar_label.clear()
1276 self.diff.clear()
1278 def files_selected(self, filenames):
1279 """Update the view when a filename is selected"""
1280 if not filenames:
1281 return
1282 oid_start = self.oid_start
1283 oid_end = self.oid_end
1284 if oid_start and oid_end:
1285 self.set_diff_range(oid_start.oid, oid_end.oid, filename=filenames[0])
1286 else:
1287 self.set_diff_oid(self.oid, filename=filenames[0])
1290 class TextLabel(QtWidgets.QLabel):
1291 def __init__(self, parent=None):
1292 QtWidgets.QLabel.__init__(self, parent)
1293 self.setTextInteractionFlags(
1294 Qt.TextSelectableByMouse | Qt.LinksAccessibleByMouse
1296 self._display = ''
1297 self._template = ''
1298 self._text = ''
1299 self._elide = False
1300 self._metrics = QtGui.QFontMetrics(self.font())
1301 self.setOpenExternalLinks(True)
1303 def elide(self):
1304 self._elide = True
1306 def set_text(self, text):
1307 self.set_template(text, text)
1309 def set_template(self, text, template):
1310 self._display = text
1311 self._text = text
1312 self._template = template
1313 self.update_text(self.width())
1314 self.setText(self._display)
1316 def update_text(self, width):
1317 self._display = self._text
1318 if not self._elide:
1319 return
1320 text = self._metrics.elidedText(self._template, Qt.ElideRight, width - 2)
1321 if text != self._template:
1322 self._display = text
1324 # Qt overrides
1325 def setFont(self, font):
1326 self._metrics = QtGui.QFontMetrics(font)
1327 QtWidgets.QLabel.setFont(self, font)
1329 def resizeEvent(self, event):
1330 if self._elide:
1331 self.update_text(event.size().width())
1332 with qtutils.BlockSignals(self):
1333 self.setText(self._display)
1334 QtWidgets.QLabel.resizeEvent(self, event)
1337 class DiffInfoTask(qtutils.Task):
1338 """Gather diffs for a single commit"""
1339 def __init__(self, context, oid, filename):
1340 qtutils.Task.__init__(self)
1341 self.context = context
1342 self.oid = oid
1343 self.filename = filename
1345 def task(self):
1346 context = self.context
1347 oid = self.oid
1348 return gitcmds.diff_info(context, oid, filename=self.filename)
1351 class DiffRangeTask(qtutils.Task):
1352 """Gather diffs for a range of commits"""
1353 def __init__(self, context, start, end, filename):
1354 qtutils.Task.__init__(self)
1355 self.context = context
1356 self.start = start
1357 self.end = end
1358 self.filename = filename
1360 def task(self):
1361 context = self.context
1362 return gitcmds.diff_range(context, self.start, self.end, filename=self.filename)