diff: add a "Search in diff" feature
[git-cola.git] / cola / widgets / diff.py
blob710ef6262a7a13cca79c39ed37e7c8123fe38d43
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 .text import TextSearchWidget
31 from . import defs
32 from . import imageview
35 class DiffSyntaxHighlighter(QtGui.QSyntaxHighlighter):
36 """Implements the diff syntax highlighting"""
38 INITIAL_STATE = -1
39 DEFAULT_STATE = 0
40 DIFFSTAT_STATE = 1
41 DIFF_FILE_HEADER_STATE = 2
42 DIFF_STATE = 3
43 SUBMODULE_STATE = 4
44 END_STATE = 5
46 DIFF_FILE_HEADER_START_RGX = re.compile(r'diff --git a/.* b/.*')
47 DIFF_HUNK_HEADER_RGX = re.compile(
48 r'(?:@@ -[0-9,]+ \+[0-9,]+ @@)|(?:@@@ (?:-[0-9,]+ ){2}\+[0-9,]+ @@@)'
50 BAD_WHITESPACE_RGX = re.compile(r'\s+$')
52 def __init__(self, context, doc, whitespace=True, is_commit=False):
53 QtGui.QSyntaxHighlighter.__init__(self, doc)
54 self.whitespace = whitespace
55 self.enabled = True
56 self.is_commit = is_commit
58 QPalette = QtGui.QPalette
59 cfg = context.cfg
60 palette = QPalette()
61 disabled = palette.color(QPalette.Disabled, QPalette.Text)
62 header = qtutils.rgb_hex(disabled)
64 dark = palette.color(QPalette.Base).lightnessF() < 0.5
66 self.color_text = qtutils.RGB(cfg.color('text', '030303'))
67 self.color_add = qtutils.RGB(cfg.color('add', '77aa77' if dark else 'd2ffe4'))
68 self.color_remove = qtutils.RGB(
69 cfg.color('remove', 'aa7777' if dark else 'fee0e4')
71 self.color_header = qtutils.RGB(cfg.color('header', header))
73 self.diff_header_fmt = qtutils.make_format(fg=self.color_header)
74 self.bold_diff_header_fmt = qtutils.make_format(fg=self.color_header, bold=True)
76 self.diff_add_fmt = qtutils.make_format(fg=self.color_text, bg=self.color_add)
77 self.diff_remove_fmt = qtutils.make_format(
78 fg=self.color_text, bg=self.color_remove
80 self.bad_whitespace_fmt = qtutils.make_format(bg=Qt.red)
81 self.setCurrentBlockState(self.INITIAL_STATE)
83 def set_enabled(self, enabled):
84 self.enabled = enabled
86 def highlightBlock(self, text):
87 if not self.enabled or not text:
88 return
89 # Aliases for quick local access
90 initial_state = self.INITIAL_STATE
91 default_state = self.DEFAULT_STATE
92 diff_state = self.DIFF_STATE
93 diffstat_state = self.DIFFSTAT_STATE
94 diff_file_header_state = self.DIFF_FILE_HEADER_STATE
95 submodule_state = self.SUBMODULE_STATE
96 end_state = self.END_STATE
98 diff_file_header_start_rgx = self.DIFF_FILE_HEADER_START_RGX
99 diff_hunk_header_rgx = self.DIFF_HUNK_HEADER_RGX
100 bad_whitespace_rgx = self.BAD_WHITESPACE_RGX
102 diff_header_fmt = self.diff_header_fmt
103 bold_diff_header_fmt = self.bold_diff_header_fmt
104 diff_add_fmt = self.diff_add_fmt
105 diff_remove_fmt = self.diff_remove_fmt
106 bad_whitespace_fmt = self.bad_whitespace_fmt
108 state = self.previousBlockState()
109 if state == initial_state:
110 if text.startswith('Submodule '):
111 state = submodule_state
112 elif text.startswith('diff --git '):
113 state = diffstat_state
114 elif self.is_commit:
115 state = default_state
116 else:
117 state = diffstat_state
119 if state == diffstat_state:
120 if diff_file_header_start_rgx.match(text):
121 state = diff_file_header_state
122 self.setFormat(0, len(text), diff_header_fmt)
123 elif diff_hunk_header_rgx.match(text):
124 state = diff_state
125 self.setFormat(0, len(text), bold_diff_header_fmt)
126 elif '|' in text:
127 i = text.index('|')
128 self.setFormat(0, i, bold_diff_header_fmt)
129 self.setFormat(i, len(text) - i, diff_header_fmt)
130 else:
131 self.setFormat(0, len(text), diff_header_fmt)
132 elif state == diff_file_header_state:
133 if diff_hunk_header_rgx.match(text):
134 state = diff_state
135 self.setFormat(0, len(text), bold_diff_header_fmt)
136 else:
137 self.setFormat(0, len(text), diff_header_fmt)
138 elif state == diff_state:
139 if diff_file_header_start_rgx.match(text):
140 state = diff_file_header_state
141 self.setFormat(0, len(text), diff_header_fmt)
142 elif diff_hunk_header_rgx.match(text):
143 self.setFormat(0, len(text), bold_diff_header_fmt)
144 elif text.startswith('-'):
145 if text == '-- ':
146 state = end_state
147 else:
148 self.setFormat(0, len(text), diff_remove_fmt)
149 elif text.startswith('+'):
150 self.setFormat(0, len(text), diff_add_fmt)
151 if self.whitespace:
152 m = bad_whitespace_rgx.search(text)
153 if m is not None:
154 i = m.start()
155 self.setFormat(i, len(text) - i, bad_whitespace_fmt)
157 self.setCurrentBlockState(state)
160 # pylint: disable=too-many-ancestors
161 class DiffTextEdit(VimHintedPlainTextEdit):
162 """A textedit for interacting with diff text"""
164 def __init__(
165 self, context, parent, is_commit=False, whitespace=True, numbers=False
167 VimHintedPlainTextEdit.__init__(self, context, '', parent=parent)
168 # Diff/patch syntax highlighter
169 self.highlighter = DiffSyntaxHighlighter(
170 context, self.document(), is_commit=is_commit, whitespace=whitespace
172 if numbers:
173 self.numbers = DiffLineNumbers(context, self)
174 self.numbers.hide()
175 else:
176 self.numbers = None
177 self.scrollvalue = None
179 self.copy_diff_action = qtutils.add_action(
180 self,
181 N_('Copy Diff'),
182 self.copy_diff,
183 hotkeys.COPY_DIFF,
185 self.copy_diff_action.setIcon(icons.copy())
186 self.copy_diff_action.setEnabled(False)
187 self.menu_actions.append(self.copy_diff_action)
189 # pylint: disable=no-member
190 self.cursorPositionChanged.connect(self._cursor_changed, Qt.QueuedConnection)
191 self.selectionChanged.connect(self._selection_changed, Qt.QueuedConnection)
193 def setFont(self, font):
194 """Override setFont() so that we can use a custom "block" cursor"""
195 super(DiffTextEdit, self).setFont(font)
196 if prefs.block_cursor(self.context):
197 metrics = QtGui.QFontMetrics(font)
198 width = metrics.width('M')
199 self.setCursorWidth(width)
201 def _cursor_changed(self):
202 """Update the line number display when the cursor changes"""
203 line_number = max(0, self.textCursor().blockNumber())
204 if self.numbers:
205 self.numbers.set_highlighted(line_number)
207 def _selection_changed(self):
208 """Respond to selection changes"""
209 selected = bool(self.selected_text())
210 self.copy_diff_action.setEnabled(selected)
212 def resizeEvent(self, event):
213 super(DiffTextEdit, self).resizeEvent(event)
214 if self.numbers:
215 self.numbers.refresh_size()
217 def save_scrollbar(self):
218 """Save the scrollbar value, but only on the first call"""
219 if self.scrollvalue is None:
220 scrollbar = self.verticalScrollBar()
221 if scrollbar:
222 scrollvalue = get(scrollbar)
223 else:
224 scrollvalue = None
225 self.scrollvalue = scrollvalue
227 def restore_scrollbar(self):
228 """Restore the scrollbar and clear state"""
229 scrollbar = self.verticalScrollBar()
230 scrollvalue = self.scrollvalue
231 if scrollbar and scrollvalue is not None:
232 scrollbar.setValue(scrollvalue)
233 self.scrollvalue = None
235 def set_loading_message(self):
236 """Add a pending loading message in the diff view"""
237 self.hint.set_value('+++ ' + N_('Loading...'))
238 self.set_value('')
240 def set_diff(self, diff):
241 """Set the diff text, but save the scrollbar"""
242 diff = diff.rstrip('\n') # diffs include two empty newlines
243 self.save_scrollbar()
245 self.hint.set_value('')
246 if self.numbers:
247 self.numbers.set_diff(diff)
248 self.set_value(diff)
250 self.restore_scrollbar()
252 def selected_diff_stripped(self):
253 """Return the selected diff stripped of any diff characters"""
254 sep, selection = self.selected_text_lines()
255 return sep.join(_strip_diff(line) for line in selection)
257 def copy_diff(self):
258 """Copy the selected diff text stripped of any diff prefix characters"""
259 text = self.selected_diff_stripped()
260 qtutils.set_clipboard(text)
262 def selected_lines(self):
263 """Return selected lines"""
264 cursor = self.textCursor()
265 selection_start = cursor.selectionStart()
266 selection_end = max(selection_start, cursor.selectionEnd() - 1)
268 first_line_idx = -1
269 last_line_idx = -1
270 line_idx = 0
271 line_start = 0
273 for line_idx, line in enumerate(get(self, default='').splitlines()):
274 line_end = line_start + len(line)
275 if line_start <= selection_start <= line_end:
276 first_line_idx = line_idx
277 if line_start <= selection_end <= line_end:
278 last_line_idx = line_idx
279 break
280 line_start = line_end + 1
282 if first_line_idx == -1:
283 first_line_idx = line_idx
285 if last_line_idx == -1:
286 last_line_idx = line_idx
288 return first_line_idx, last_line_idx
290 def selected_text_lines(self):
291 """Return selected lines and the CRLF / LF separator"""
292 first_line_idx, last_line_idx = self.selected_lines()
293 text = get(self, default='')
294 sep = _get_sep(text)
295 lines = []
296 for line_idx, line in enumerate(text.split(sep)):
297 if first_line_idx <= line_idx <= last_line_idx:
298 lines.append(line)
299 return sep, lines
302 def _get_sep(text):
303 """Return either CRLF or LF based on the content"""
304 if '\r\n' in text:
305 sep = '\r\n'
306 else:
307 sep = '\n'
308 return sep
311 def _strip_diff(value):
312 """Remove +/-/<space> from a selection"""
313 if value.startswith(('+', '-', ' ')):
314 return value[1:]
315 return value
318 class DiffLineNumbers(TextDecorator):
319 def __init__(self, context, parent):
320 TextDecorator.__init__(self, parent)
321 self.highlight_line = -1
322 self.lines = None
323 self.parser = diffparse.DiffLines()
324 self.formatter = diffparse.FormatDigits()
326 self.setFont(qtutils.diff_font(context))
327 self._char_width = self.fontMetrics().width('0')
329 QPalette = QtGui.QPalette
330 self._palette = palette = self.palette()
331 self._base = palette.color(QtGui.QPalette.Base)
332 self._highlight = palette.color(QPalette.Highlight)
333 self._highlight.setAlphaF(0.3)
334 self._highlight_text = palette.color(QPalette.HighlightedText)
335 self._window = palette.color(QPalette.Window)
336 self._disabled = palette.color(QPalette.Disabled, QPalette.Text)
338 def set_diff(self, diff):
339 self.lines = self.parser.parse(diff)
340 self.formatter.set_digits(self.parser.digits())
342 def width_hint(self):
343 if not self.isVisible():
344 return 0
345 parser = self.parser
347 if parser.merge:
348 columns = 3
349 extra = 3 # one space in-between, one space after
350 else:
351 columns = 2
352 extra = 2 # one space in-between, one space after
354 digits = parser.digits() * columns
356 return defs.margin + (self._char_width * (digits + extra))
358 def set_highlighted(self, line_number):
359 """Set the line to highlight"""
360 self.highlight_line = line_number
362 def current_line(self):
363 lines = self.lines
364 if lines and self.highlight_line >= 0:
365 # Find the next valid line
366 for i in range(self.highlight_line, len(lines)):
367 # take the "new" line number: last value in tuple
368 line_number = lines[i][-1]
369 if line_number > 0:
370 return line_number
372 # Find the previous valid line
373 for i in range(self.highlight_line - 1, -1, -1):
374 # take the "new" line number: last value in tuple
375 if i < len(lines):
376 line_number = lines[i][-1]
377 if line_number > 0:
378 return line_number
379 return None
381 def paintEvent(self, event):
382 """Paint the line number"""
383 if not self.lines:
384 return
386 painter = QtGui.QPainter(self)
387 painter.fillRect(event.rect(), self._base)
389 editor = self.editor
390 content_offset = editor.contentOffset()
391 block = editor.firstVisibleBlock()
392 width = self.width()
393 text_width = width - (defs.margin * 2)
394 text_flags = Qt.AlignRight | Qt.AlignVCenter
395 event_rect_bottom = event.rect().bottom()
397 highlight_line = self.highlight_line
398 highlight = self._highlight
399 highlight_text = self._highlight_text
400 disabled = self._disabled
402 fmt = self.formatter
403 lines = self.lines
404 num_lines = len(lines)
406 while block.isValid():
407 block_number = block.blockNumber()
408 if block_number >= num_lines:
409 break
410 block_geom = editor.blockBoundingGeometry(block)
411 rect = block_geom.translated(content_offset).toRect()
412 if not block.isVisible() or rect.top() >= event_rect_bottom:
413 break
415 if block_number == highlight_line:
416 painter.fillRect(rect.x(), rect.y(), width, rect.height(), highlight)
417 painter.setPen(highlight_text)
418 else:
419 painter.setPen(disabled)
421 line = lines[block_number]
422 if len(line) == 2:
423 a, b = line
424 text = fmt.value(a, b)
425 elif len(line) == 3:
426 old, base, new = line
427 text = fmt.merge_value(old, base, new)
429 painter.drawText(
430 rect.x(),
431 rect.y(),
432 text_width,
433 rect.height(),
434 text_flags,
435 text,
438 block = block.next() # pylint: disable=next-method-called
441 class Viewer(QtWidgets.QFrame):
442 """Text and image diff viewers"""
443 INDEX_TEXT = 0
444 INDEX_IMAGE = 1
446 def __init__(self, context, parent=None):
447 super(Viewer, self).__init__(parent)
449 self.context = context
450 self.model = model = context.model
451 self.images = []
452 self.pixmaps = []
453 self.options = options = Options(self)
454 self.text = DiffEditor(context, options, self)
455 self.image = imageview.ImageView(parent=self)
456 self.image.setFocusPolicy(Qt.NoFocus)
457 self.search_widget = TextSearchWidget(self)
458 self.search_widget.hide()
460 stack = self.stack = QtWidgets.QStackedWidget(self)
461 stack.addWidget(self.text)
462 stack.addWidget(self.image)
464 self.main_layout = qtutils.vbox(
465 defs.no_margin,
466 defs.no_spacing,
467 self.stack,
468 self.search_widget,
470 self.setLayout(self.main_layout)
472 # Observe images
473 model.images_changed.connect(self.set_images, type=Qt.QueuedConnection)
475 # Observe the diff type
476 model.diff_type_changed.connect(self.set_diff_type, type=Qt.QueuedConnection)
478 # Observe the file type
479 model.file_type_changed.connect(self.set_file_type, type=Qt.QueuedConnection)
481 # Observe the image mode combo box
482 options.image_mode.currentIndexChanged.connect(lambda _: self.render())
483 options.zoom_mode.currentIndexChanged.connect(lambda _: self.render())
485 self.setFocusProxy(self.text)
487 self.search_widget.search_text.connect(self.search_text)
489 self.search_action = qtutils.add_action(
490 self,
491 N_('Search in Diff'),
492 self.show_search_diff,
493 hotkeys.SEARCH,
495 self.search_next_action = qtutils.add_action(
496 self,
497 N_('Find next item'),
498 self.search_widget.search,
499 hotkeys.SEARCH_NEXT,
501 self.search_prev_action = qtutils.add_action(
502 self,
503 N_('Find previous item'),
504 self.search_widget.search_backwards,
505 hotkeys.SEARCH_PREV,
508 def show_search_diff(self):
509 """Show a dialog for searching diffs"""
510 # The diff search is only active in text mode.
511 if self.stack.currentIndex() != self.INDEX_TEXT:
512 return
513 if not self.search_widget.isVisible():
514 self.search_widget.show()
515 self.search_widget.setFocus(True)
517 def search_text(self, text, backwards):
518 """Search the diff text for the given text"""
519 cursor = self.text.textCursor()
520 if cursor.hasSelection():
521 selected_text = cursor.selectedText()
522 case_sensitive = self.search_widget.is_case_sensitive()
523 if text_matches(case_sensitive, selected_text, text):
524 if backwards:
525 position = cursor.selectionStart()
526 else:
527 position = cursor.selectionEnd()
528 else:
529 if backwards:
530 position = cursor.selectionEnd()
531 else:
532 position = cursor.selectionStart()
533 cursor.setPosition(position)
534 self.text.setTextCursor(cursor)
536 flags = self.search_widget.find_flags(backwards)
537 if not self.text.find(text, flags):
538 if backwards:
539 location = QtGui.QTextCursor.End
540 else:
541 location = QtGui.QTextCursor.Start
542 cursor.movePosition(location, QtGui.QTextCursor.MoveAnchor)
543 self.text.setTextCursor(cursor)
544 self.text.find(text, flags)
546 def export_state(self, state):
547 state['show_diff_line_numbers'] = self.options.show_line_numbers.isChecked()
548 state['image_diff_mode'] = self.options.image_mode.currentIndex()
549 state['image_zoom_mode'] = self.options.zoom_mode.currentIndex()
550 state['word_wrap'] = self.options.enable_word_wrapping.isChecked()
551 return state
553 def apply_state(self, state):
554 diff_numbers = bool(state.get('show_diff_line_numbers', False))
555 self.set_line_numbers(diff_numbers, update=True)
557 image_mode = utils.asint(state.get('image_diff_mode', 0))
558 self.options.image_mode.set_index(image_mode)
560 zoom_mode = utils.asint(state.get('image_zoom_mode', 0))
561 self.options.zoom_mode.set_index(zoom_mode)
563 word_wrap = bool(state.get('word_wrap', True))
564 self.set_word_wrapping(word_wrap, update=True)
565 return True
567 def set_diff_type(self, diff_type):
568 """Manage the image and text diff views when selection changes"""
569 # The "diff type" is whether the diff viewer is displaying an image.
570 self.options.set_diff_type(diff_type)
571 if diff_type == main.Types.IMAGE:
572 self.stack.setCurrentWidget(self.image)
573 self.search_widget.hide()
574 self.render()
575 else:
576 self.stack.setCurrentWidget(self.text)
578 def set_file_type(self, file_type):
579 """Manage the diff options when the file type changes"""
580 # The "file type" is whether the file itself is an image.
581 self.options.set_file_type(file_type)
583 def update_options(self):
584 """Emit a signal indicating that options have changed"""
585 self.text.update_options()
587 def set_line_numbers(self, enabled, update=False):
588 """Enable/disable line numbers in the text widget"""
589 self.text.set_line_numbers(enabled, update=update)
591 def set_word_wrapping(self, enabled, update=False):
592 """Enable/disable word wrapping in the text widget"""
593 self.text.set_word_wrapping(enabled, update=update)
595 def reset(self):
596 self.image.pixmap = QtGui.QPixmap()
597 self.cleanup()
599 def cleanup(self):
600 for (image, unlink) in self.images:
601 if unlink and core.exists(image):
602 os.unlink(image)
603 self.images = []
605 def set_images(self, images):
606 self.images = images
607 self.pixmaps = []
608 if not images:
609 self.reset()
610 return False
612 # In order to comp, we first have to load all the images
613 all_pixmaps = [QtGui.QPixmap(image[0]) for image in images]
614 pixmaps = [pixmap for pixmap in all_pixmaps if not pixmap.isNull()]
615 if not pixmaps:
616 self.reset()
617 return False
619 self.pixmaps = pixmaps
620 self.render()
621 self.cleanup()
622 return True
624 def render(self):
625 # Update images
626 if self.pixmaps:
627 mode = self.options.image_mode.currentIndex()
628 if mode == self.options.SIDE_BY_SIDE:
629 image = self.render_side_by_side()
630 elif mode == self.options.DIFF:
631 image = self.render_diff()
632 elif mode == self.options.XOR:
633 image = self.render_xor()
634 elif mode == self.options.PIXEL_XOR:
635 image = self.render_pixel_xor()
636 else:
637 image = self.render_side_by_side()
638 else:
639 image = QtGui.QPixmap()
640 self.image.pixmap = image
642 # Apply zoom
643 zoom_mode = self.options.zoom_mode.currentIndex()
644 zoom_factor = self.options.zoom_factors[zoom_mode][1]
645 if zoom_factor > 0.0:
646 self.image.resetTransform()
647 self.image.scale(zoom_factor, zoom_factor)
648 poly = self.image.mapToScene(self.image.viewport().rect())
649 self.image.last_scene_roi = poly.boundingRect()
651 def render_side_by_side(self):
652 # Side-by-side lineup comp
653 pixmaps = self.pixmaps
654 width = sum(pixmap.width() for pixmap in pixmaps)
655 height = max(pixmap.height() for pixmap in pixmaps)
656 image = create_image(width, height)
658 # Paint each pixmap
659 painter = create_painter(image)
660 x = 0
661 for pixmap in pixmaps:
662 painter.drawPixmap(x, 0, pixmap)
663 x += pixmap.width()
664 painter.end()
666 return image
668 def render_comp(self, comp_mode):
669 # Get the max size to use as the render canvas
670 pixmaps = self.pixmaps
671 if len(pixmaps) == 1:
672 return pixmaps[0]
674 width = max(pixmap.width() for pixmap in pixmaps)
675 height = max(pixmap.height() for pixmap in pixmaps)
676 image = create_image(width, height)
678 painter = create_painter(image)
679 for pixmap in (pixmaps[0], pixmaps[-1]):
680 x = (width - pixmap.width()) // 2
681 y = (height - pixmap.height()) // 2
682 painter.drawPixmap(x, y, pixmap)
683 painter.setCompositionMode(comp_mode)
684 painter.end()
686 return image
688 def render_diff(self):
689 comp_mode = QtGui.QPainter.CompositionMode_Difference
690 return self.render_comp(comp_mode)
692 def render_xor(self):
693 comp_mode = QtGui.QPainter.CompositionMode_Xor
694 return self.render_comp(comp_mode)
696 def render_pixel_xor(self):
697 comp_mode = QtGui.QPainter.RasterOp_SourceXorDestination
698 return self.render_comp(comp_mode)
701 def create_image(width, height):
702 size = QtCore.QSize(width, height)
703 image = QtGui.QImage(size, QtGui.QImage.Format_ARGB32_Premultiplied)
704 image.fill(Qt.transparent)
705 return image
708 def text_matches(case_sensitive, a, b):
709 """Compare text with case sensitivity taken into account"""
710 if case_sensitive:
711 return a == b
712 return a.lower() == b.lower()
715 def create_painter(image):
716 painter = QtGui.QPainter(image)
717 painter.fillRect(image.rect(), Qt.transparent)
718 return painter
721 class Options(QtWidgets.QWidget):
722 """Provide the options widget used by the editor
724 Actions are registered on the parent widget.
728 # mode combobox indexes
729 SIDE_BY_SIDE = 0
730 DIFF = 1
731 XOR = 2
732 PIXEL_XOR = 3
734 def __init__(self, parent):
735 super(Options, self).__init__(parent)
736 # Create widgets
737 self.widget = parent
738 self.ignore_space_at_eol = self.add_option(
739 N_('Ignore changes in whitespace at EOL')
742 self.ignore_space_change = self.add_option(
743 N_('Ignore changes in amount of whitespace')
746 self.ignore_all_space = self.add_option(N_('Ignore all whitespace'))
748 self.function_context = self.add_option(
749 N_('Show whole surrounding functions of changes')
752 self.show_line_numbers = qtutils.add_action_bool(
753 self, N_('Show line numbers'), self.set_line_numbers, True
755 self.enable_word_wrapping = qtutils.add_action_bool(
756 self, N_('Enable word wrapping'), self.set_word_wrapping, True
759 self.options = qtutils.create_action_button(
760 tooltip=N_('Diff Options'), icon=icons.configure()
763 self.toggle_image_diff = qtutils.create_action_button(
764 tooltip=N_('Toggle image diff'), icon=icons.visualize()
766 self.toggle_image_diff.hide()
768 self.image_mode = qtutils.combo(
769 [N_('Side by side'), N_('Diff'), N_('XOR'), N_('Pixel XOR')]
772 self.zoom_factors = (
773 (N_('Zoom to Fit'), 0.0),
774 (N_('25%'), 0.25),
775 (N_('50%'), 0.5),
776 (N_('100%'), 1.0),
777 (N_('200%'), 2.0),
778 (N_('400%'), 4.0),
779 (N_('800%'), 8.0),
781 zoom_modes = [factor[0] for factor in self.zoom_factors]
782 self.zoom_mode = qtutils.combo(zoom_modes, parent=self)
784 self.menu = menu = qtutils.create_menu(N_('Diff Options'), self.options)
785 self.options.setMenu(menu)
786 menu.addAction(self.ignore_space_at_eol)
787 menu.addAction(self.ignore_space_change)
788 menu.addAction(self.ignore_all_space)
789 menu.addSeparator()
790 menu.addAction(self.function_context)
791 menu.addAction(self.show_line_numbers)
792 menu.addSeparator()
793 menu.addAction(self.enable_word_wrapping)
795 # Layouts
796 layout = qtutils.hbox(
797 defs.no_margin,
798 defs.button_spacing,
799 self.image_mode,
800 self.zoom_mode,
801 self.options,
802 self.toggle_image_diff,
804 self.setLayout(layout)
806 # Policies
807 self.image_mode.setFocusPolicy(Qt.NoFocus)
808 self.zoom_mode.setFocusPolicy(Qt.NoFocus)
809 self.options.setFocusPolicy(Qt.NoFocus)
810 self.toggle_image_diff.setFocusPolicy(Qt.NoFocus)
811 self.setFocusPolicy(Qt.NoFocus)
813 def set_file_type(self, file_type):
814 """Set whether we are viewing an image file type"""
815 is_image = file_type == main.Types.IMAGE
816 self.toggle_image_diff.setVisible(is_image)
818 def set_diff_type(self, diff_type):
819 """Toggle between image and text diffs"""
820 is_text = diff_type == main.Types.TEXT
821 is_image = diff_type == main.Types.IMAGE
822 self.options.setVisible(is_text)
823 self.image_mode.setVisible(is_image)
824 self.zoom_mode.setVisible(is_image)
825 if is_image:
826 self.toggle_image_diff.setIcon(icons.diff())
827 else:
828 self.toggle_image_diff.setIcon(icons.visualize())
830 def add_option(self, title):
831 """Add a diff option which calls update_options() on change"""
832 action = qtutils.add_action(self, title, self.update_options)
833 action.setCheckable(True)
834 return action
836 def update_options(self):
837 """Update diff options in response to UI events"""
838 space_at_eol = get(self.ignore_space_at_eol)
839 space_change = get(self.ignore_space_change)
840 all_space = get(self.ignore_all_space)
841 function_context = get(self.function_context)
842 gitcmds.update_diff_overrides(
843 space_at_eol, space_change, all_space, function_context
845 self.widget.update_options()
847 def set_line_numbers(self, value):
848 """Enable / disable line numbers"""
849 self.widget.set_line_numbers(value, update=False)
851 def set_word_wrapping(self, value):
852 """Respond to Qt action callbacks"""
853 self.widget.set_word_wrapping(value, update=False)
855 def hide_advanced_options(self):
856 """Hide advanced options that are not applicable to the DiffWidget"""
857 self.show_line_numbers.setVisible(False)
858 self.ignore_space_at_eol.setVisible(False)
859 self.ignore_space_change.setVisible(False)
860 self.ignore_all_space.setVisible(False)
861 self.function_context.setVisible(False)
864 # pylint: disable=too-many-ancestors
865 class DiffEditor(DiffTextEdit):
867 up = Signal()
868 down = Signal()
869 options_changed = Signal()
871 def __init__(self, context, options, parent):
872 DiffTextEdit.__init__(self, context, parent, numbers=True)
873 self.context = context
874 self.model = model = context.model
875 self.selection_model = selection_model = context.selection
877 # "Diff Options" tool menu
878 self.options = options
880 self.action_apply_selection = qtutils.add_action(
881 self,
882 'Apply',
883 self.apply_selection,
884 hotkeys.STAGE_DIFF,
885 hotkeys.STAGE_DIFF_ALT,
888 self.action_revert_selection = qtutils.add_action(
889 self, 'Revert', self.revert_selection, hotkeys.REVERT
891 self.action_revert_selection.setIcon(icons.undo())
893 self.action_edit_and_apply_selection = qtutils.add_action(
894 self,
895 'Edit and Apply',
896 partial(self.apply_selection, edit=True),
897 hotkeys.EDIT_AND_STAGE_DIFF,
900 self.action_edit_and_revert_selection = qtutils.add_action(
901 self,
902 'Edit and Revert',
903 partial(self.revert_selection, edit=True),
904 hotkeys.EDIT_AND_REVERT,
906 self.action_edit_and_revert_selection.setIcon(icons.undo())
907 self.launch_editor = actions.launch_editor_at_line(
908 context, self, hotkeys.EDIT_SHORT, *hotkeys.ACCEPT
910 self.launch_difftool = actions.launch_difftool(context, self)
911 self.stage_or_unstage = actions.stage_or_unstage(context, self)
913 # Emit up/down signals so that they can be routed by the main widget
914 self.move_up = actions.move_up(self)
915 self.move_down = actions.move_down(self)
917 model.diff_text_updated.connect(self.set_diff, type=Qt.QueuedConnection)
919 selection_model.selection_changed.connect(
920 self.refresh, type=Qt.QueuedConnection
922 # Update the selection model when the cursor changes
923 self.cursorPositionChanged.connect(self._update_line_number)
925 qtutils.connect_button(options.toggle_image_diff, self.toggle_diff_type)
927 def toggle_diff_type(self):
928 cmds.do(cmds.ToggleDiffType, self.context)
930 def refresh(self):
931 enabled = False
932 s = self.selection_model.selection()
933 model = self.model
934 if model.is_partially_stageable():
935 item = s.modified[0] if s.modified else None
936 if item in model.submodules:
937 pass
938 elif item not in model.unstaged_deleted:
939 enabled = True
940 self.action_revert_selection.setEnabled(enabled)
942 def set_line_numbers(self, enabled, update=False):
943 """Enable/disable the diff line number display"""
944 self.numbers.setVisible(enabled)
945 if update:
946 with qtutils.BlockSignals(self.options.show_line_numbers):
947 self.options.show_line_numbers.setChecked(enabled)
948 # Refresh the display. Not doing this results in the display not
949 # correctly displaying the line numbers widget until the text scrolls.
950 self.set_value(self.value())
952 def update_options(self):
953 self.options_changed.emit()
955 def create_context_menu(self, event_pos):
956 """Override create_context_menu() to display a completely custom menu"""
957 menu = super(DiffEditor, self).create_context_menu(event_pos)
958 context = self.context
959 model = self.model
960 s = self.selection_model.selection()
961 filename = self.selection_model.filename()
963 # These menu actions will be inserted at the start of the widget.
964 current_actions = menu.actions()
965 menu_actions = []
966 add_action = menu_actions.append
968 if model.is_stageable() or model.is_unstageable():
969 if model.is_stageable():
970 self.stage_or_unstage.setText(N_('Stage'))
971 self.stage_or_unstage.setIcon(icons.add())
972 else:
973 self.stage_or_unstage.setText(N_('Unstage'))
974 self.stage_or_unstage.setIcon(icons.remove())
975 add_action(self.stage_or_unstage)
977 if model.is_partially_stageable():
978 item = s.modified[0] if s.modified else None
979 if item in model.submodules:
980 path = core.abspath(item)
981 action = qtutils.add_action_with_icon(
982 menu,
983 icons.add(),
984 cmds.Stage.name(),
985 cmds.run(cmds.Stage, context, s.modified),
986 hotkeys.STAGE_SELECTION,
988 add_action(action)
990 action = qtutils.add_action_with_icon(
991 menu,
992 icons.cola(),
993 N_('Launch git-cola'),
994 cmds.run(cmds.OpenRepo, context, path),
996 add_action(action)
997 elif item not in model.unstaged_deleted:
998 if self.has_selection():
999 apply_text = N_('Stage Selected Lines')
1000 edit_and_apply_text = N_('Edit Selected Lines to Stage...')
1001 revert_text = N_('Revert Selected Lines...')
1002 edit_and_revert_text = N_('Edit Selected Lines to Revert...')
1003 else:
1004 apply_text = N_('Stage Diff Hunk')
1005 edit_and_apply_text = N_('Edit Diff Hunk to Stage...')
1006 revert_text = N_('Revert Diff Hunk...')
1007 edit_and_revert_text = N_('Edit Diff Hunk to Revert...')
1009 self.action_apply_selection.setText(apply_text)
1010 self.action_apply_selection.setIcon(icons.add())
1011 add_action(self.action_apply_selection)
1013 self.action_edit_and_apply_selection.setText(edit_and_apply_text)
1014 self.action_edit_and_apply_selection.setIcon(icons.add())
1015 add_action(self.action_edit_and_apply_selection)
1017 self.action_revert_selection.setText(revert_text)
1018 add_action(self.action_revert_selection)
1020 self.action_edit_and_revert_selection.setText(edit_and_revert_text)
1021 add_action(self.action_edit_and_revert_selection)
1023 if s.staged and model.is_unstageable():
1024 item = s.staged[0]
1025 if item in model.submodules:
1026 path = core.abspath(item)
1027 action = qtutils.add_action_with_icon(
1028 menu,
1029 icons.remove(),
1030 cmds.Unstage.name(),
1031 cmds.run(cmds.Unstage, context, s.staged),
1032 hotkeys.STAGE_SELECTION,
1034 add_action(action)
1036 qtutils.add_action_with_icon(
1037 menu,
1038 icons.cola(),
1039 N_('Launch git-cola'),
1040 cmds.run(cmds.OpenRepo, context, path),
1042 add_action(action)
1044 elif item not in model.staged_deleted:
1045 if self.has_selection():
1046 apply_text = N_('Unstage Selected Lines')
1047 edit_and_apply_text = N_('Edit Selected Lines to Unstage...')
1048 else:
1049 apply_text = N_('Unstage Diff Hunk')
1050 edit_and_apply_text = N_('Edit Diff Hunk to Unstage...')
1052 self.action_apply_selection.setText(apply_text)
1053 self.action_apply_selection.setIcon(icons.remove())
1054 add_action(self.action_apply_selection)
1056 self.action_edit_and_apply_selection.setText(edit_and_apply_text)
1057 self.action_edit_and_apply_selection.setIcon(icons.remove())
1058 add_action(self.action_edit_and_apply_selection)
1060 if model.is_stageable() or model.is_unstageable():
1061 # Do not show the "edit" action when the file does not exist.
1062 # Untracked files exist by definition.
1063 if filename and core.exists(filename):
1064 add_action(qtutils.menu_separator(menu))
1065 add_action(self.launch_editor)
1067 # Removed files can still be diffed.
1068 add_action(self.launch_difftool)
1070 # Add the Previous/Next File actions, which improves discoverability
1071 # of their associated shortcuts
1072 add_action(qtutils.menu_separator(menu))
1073 add_action(self.move_up)
1074 add_action(self.move_down)
1075 add_action(qtutils.menu_separator(menu))
1077 if current_actions:
1078 first_action = current_actions[0]
1079 else:
1080 first_action = None
1081 menu.insertActions(first_action, menu_actions)
1083 return menu
1085 def mousePressEvent(self, event):
1086 if event.button() == Qt.RightButton:
1087 # Intercept right-click to move the cursor to the current position.
1088 # setTextCursor() clears the selection so this is only done when
1089 # nothing is selected.
1090 if not self.has_selection():
1091 cursor = self.cursorForPosition(event.pos())
1092 self.setTextCursor(cursor)
1094 return super(DiffEditor, self).mousePressEvent(event)
1096 def setPlainText(self, text):
1097 """setPlainText(str) while retaining scrollbar positions"""
1098 model = self.model
1099 mode = model.mode
1100 highlight = mode not in (
1101 model.mode_none,
1102 model.mode_display,
1103 model.mode_untracked,
1105 self.highlighter.set_enabled(highlight)
1107 scrollbar = self.verticalScrollBar()
1108 if scrollbar:
1109 scrollvalue = get(scrollbar)
1110 else:
1111 scrollvalue = None
1113 if text is None:
1114 return
1116 DiffTextEdit.setPlainText(self, text)
1118 if scrollbar and scrollvalue is not None:
1119 scrollbar.setValue(scrollvalue)
1121 def apply_selection(self, *, edit=False):
1122 model = self.model
1123 s = self.selection_model.single_selection()
1124 if model.is_partially_stageable() and (s.modified or s.untracked):
1125 self.process_diff_selection(edit=edit)
1126 elif model.is_unstageable():
1127 self.process_diff_selection(reverse=True, edit=edit)
1129 def revert_selection(self, *, edit=False):
1130 """Destructively revert selected lines or hunk from a worktree file."""
1132 if not edit:
1133 if self.has_selection():
1134 title = N_('Revert Selected Lines?')
1135 ok_text = N_('Revert Selected Lines')
1136 else:
1137 title = N_('Revert Diff Hunk?')
1138 ok_text = N_('Revert Diff Hunk')
1140 if not Interaction.confirm(
1141 title,
1143 'This operation drops uncommitted changes.\n'
1144 'These changes cannot be recovered.'
1146 N_('Revert the uncommitted changes?'),
1147 ok_text,
1148 default=True,
1149 icon=icons.undo(),
1151 return
1152 self.process_diff_selection(reverse=True, apply_to_worktree=True, edit=edit)
1154 def extract_patch(self, reverse=False):
1155 first_line_idx, last_line_idx = self.selected_lines()
1156 patch = diffparse.Patch.parse(self.model.filename, self.model.diff_text)
1157 if self.has_selection():
1158 return patch.extract_subset(first_line_idx, last_line_idx, reverse=reverse)
1159 else:
1160 return patch.extract_hunk(first_line_idx, reverse=reverse)
1162 def patch_encoding(self):
1163 if isinstance(self.model.diff_text, core.UStr):
1164 # original encoding must prevail
1165 return self.model.diff_text.encoding
1166 else:
1167 return self.context.cfg.file_encoding(self.model.filename)
1169 def process_diff_selection(
1170 self, reverse=False, apply_to_worktree=False, edit=False
1172 """Implement un/staging of the selected line(s) or hunk."""
1173 if self.selection_model.is_empty():
1174 return
1175 patch = self.extract_patch(reverse)
1176 if not patch.has_changes():
1177 return
1178 patch_encoding = self.patch_encoding()
1180 if edit:
1181 patch = edit_patch(
1182 patch,
1183 patch_encoding,
1184 self.context,
1185 reverse=reverse,
1186 apply_to_worktree=apply_to_worktree,
1188 if not patch.has_changes():
1189 return
1191 cmds.do(
1192 cmds.ApplyPatch,
1193 self.context,
1194 patch,
1195 patch_encoding,
1196 apply_to_worktree,
1199 def _update_line_number(self):
1200 """Update the selection model when the cursor changes"""
1201 self.selection_model.line_number = self.numbers.current_line()
1204 class DiffWidget(QtWidgets.QWidget):
1205 """Display commit metadata and text diffs"""
1207 def __init__(self, context, parent, is_commit=False, options=None):
1208 QtWidgets.QWidget.__init__(self, parent)
1210 self.context = context
1211 self.oid = 'HEAD'
1212 self.oid_start = None
1213 self.oid_end = None
1214 self.options = options
1216 author_font = QtGui.QFont(self.font())
1217 author_font.setPointSize(int(author_font.pointSize() * 1.1))
1219 summary_font = QtGui.QFont(author_font)
1220 summary_font.setWeight(QtGui.QFont.Bold)
1222 policy = QtWidgets.QSizePolicy(
1223 QtWidgets.QSizePolicy.MinimumExpanding, QtWidgets.QSizePolicy.Minimum
1226 self.gravatar_label = gravatar.GravatarLabel(self.context, parent=self)
1228 self.author_label = TextLabel()
1229 self.author_label.setTextFormat(Qt.RichText)
1230 self.author_label.setFont(author_font)
1231 self.author_label.setSizePolicy(policy)
1232 self.author_label.setAlignment(Qt.AlignBottom)
1233 self.author_label.elide()
1235 self.date_label = TextLabel()
1236 self.date_label.setTextFormat(Qt.PlainText)
1237 self.date_label.setSizePolicy(policy)
1238 self.date_label.setAlignment(Qt.AlignTop)
1239 self.date_label.hide()
1241 self.summary_label = TextLabel()
1242 self.summary_label.setTextFormat(Qt.PlainText)
1243 self.summary_label.setFont(summary_font)
1244 self.summary_label.setSizePolicy(policy)
1245 self.summary_label.setAlignment(Qt.AlignTop)
1246 self.summary_label.elide()
1248 self.oid_label = TextLabel()
1249 self.oid_label.setTextFormat(Qt.PlainText)
1250 self.oid_label.setSizePolicy(policy)
1251 self.oid_label.setAlignment(Qt.AlignTop)
1252 self.oid_label.elide()
1254 self.diff = DiffTextEdit(context, self, is_commit=is_commit, whitespace=False)
1255 self.setFocusProxy(self.diff)
1257 self.info_layout = qtutils.vbox(
1258 defs.no_margin,
1259 defs.no_spacing,
1260 self.author_label,
1261 self.date_label,
1262 self.summary_label,
1263 self.oid_label,
1266 self.logo_layout = qtutils.hbox(
1267 defs.no_margin, defs.button_spacing, self.gravatar_label, self.info_layout
1269 self.logo_layout.setContentsMargins(defs.margin, 0, defs.margin, 0)
1271 self.main_layout = qtutils.vbox(
1272 defs.no_margin, defs.spacing, self.logo_layout, self.diff
1274 self.setLayout(self.main_layout)
1276 self.set_tabwidth(prefs.tabwidth(context))
1278 def set_tabwidth(self, width):
1279 self.diff.set_tabwidth(width)
1281 def set_word_wrapping(self, enabled, update=False):
1282 """Enable and disable word wrapping"""
1283 self.diff.set_word_wrapping(enabled, update=update)
1285 def set_options(self, options):
1286 """Register an options widget"""
1287 self.options = options
1288 self.diff.set_options(options)
1290 def start_diff_task(self, task):
1291 """Clear the display and start a diff-gathering task"""
1292 self.diff.save_scrollbar()
1293 self.diff.set_loading_message()
1294 self.context.runtask.start(task, result=self.set_diff)
1296 def set_diff_oid(self, oid, filename=None):
1297 """Set the diff from a single commit object ID"""
1298 task = DiffInfoTask(self.context, oid, filename)
1299 self.start_diff_task(task)
1301 def set_diff_range(self, start, end, filename=None):
1302 task = DiffRangeTask(self.context, start + '~', end, filename)
1303 self.start_diff_task(task)
1305 def commits_selected(self, commits):
1306 """Display an appropriate diff when commits are selected"""
1307 if not commits:
1308 self.clear()
1309 return
1310 commit = commits[-1]
1311 oid = commit.oid
1312 email = commit.email or ''
1313 summary = commit.summary or ''
1314 author = commit.author or ''
1315 self.set_details(oid, author, email, '', summary)
1316 self.oid = oid
1318 if len(commits) > 1:
1319 start, end = commits[0], commits[-1]
1320 self.set_diff_range(start.oid, end.oid)
1321 self.oid_start = start
1322 self.oid_end = end
1323 else:
1324 self.set_diff_oid(oid)
1325 self.oid_start = None
1326 self.oid_end = None
1328 def set_diff(self, diff):
1329 """Set the diff text"""
1330 self.diff.set_diff(diff)
1332 def set_details(self, oid, author, email, date, summary):
1333 template_args = {'author': author, 'email': email, 'summary': summary}
1334 author_text = (
1335 """%(author)s &lt;"""
1336 """<a href="mailto:%(email)s">"""
1337 """%(email)s</a>&gt;""" % template_args
1339 author_template = '%(author)s <%(email)s>' % template_args
1341 self.date_label.set_text(date)
1342 self.date_label.setVisible(bool(date))
1343 self.oid_label.set_text(oid)
1344 self.author_label.set_template(author_text, author_template)
1345 self.summary_label.set_text(summary)
1346 self.gravatar_label.set_email(email)
1348 def clear(self):
1349 self.date_label.set_text('')
1350 self.oid_label.set_text('')
1351 self.author_label.set_text('')
1352 self.summary_label.set_text('')
1353 self.gravatar_label.clear()
1354 self.diff.clear()
1356 def files_selected(self, filenames):
1357 """Update the view when a filename is selected"""
1358 if not filenames:
1359 return
1360 oid_start = self.oid_start
1361 oid_end = self.oid_end
1362 if oid_start and oid_end:
1363 self.set_diff_range(oid_start.oid, oid_end.oid, filename=filenames[0])
1364 else:
1365 self.set_diff_oid(self.oid, filename=filenames[0])
1368 class TextLabel(QtWidgets.QLabel):
1369 def __init__(self, parent=None):
1370 QtWidgets.QLabel.__init__(self, parent)
1371 self.setTextInteractionFlags(
1372 Qt.TextSelectableByMouse | Qt.LinksAccessibleByMouse
1374 self._display = ''
1375 self._template = ''
1376 self._text = ''
1377 self._elide = False
1378 self._metrics = QtGui.QFontMetrics(self.font())
1379 self.setOpenExternalLinks(True)
1381 def elide(self):
1382 self._elide = True
1384 def set_text(self, text):
1385 self.set_template(text, text)
1387 def set_template(self, text, template):
1388 self._display = text
1389 self._text = text
1390 self._template = template
1391 self.update_text(self.width())
1392 self.setText(self._display)
1394 def update_text(self, width):
1395 self._display = self._text
1396 if not self._elide:
1397 return
1398 text = self._metrics.elidedText(self._template, Qt.ElideRight, width - 2)
1399 if text != self._template:
1400 self._display = text
1402 # Qt overrides
1403 def setFont(self, font):
1404 self._metrics = QtGui.QFontMetrics(font)
1405 QtWidgets.QLabel.setFont(self, font)
1407 def resizeEvent(self, event):
1408 if self._elide:
1409 self.update_text(event.size().width())
1410 with qtutils.BlockSignals(self):
1411 self.setText(self._display)
1412 QtWidgets.QLabel.resizeEvent(self, event)
1415 class DiffInfoTask(qtutils.Task):
1416 """Gather diffs for a single commit"""
1418 def __init__(self, context, oid, filename):
1419 qtutils.Task.__init__(self)
1420 self.context = context
1421 self.oid = oid
1422 self.filename = filename
1424 def task(self):
1425 context = self.context
1426 oid = self.oid
1427 return gitcmds.diff_info(context, oid, filename=self.filename)
1430 class DiffRangeTask(qtutils.Task):
1431 """Gather diffs for a range of commits"""
1433 def __init__(self, context, start, end, filename):
1434 qtutils.Task.__init__(self)
1435 self.context = context
1436 self.start = start
1437 self.end = end
1438 self.filename = filename
1440 def task(self):
1441 context = self.context
1442 return gitcmds.diff_range(context, self.start, self.end, filename=self.filename)