pylint: return unnecessary else after return
[git-cola.git] / cola / widgets / diff.py
blobb02b1348f2d00a18afd019b10544dbdf17f52a5c
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 """Highlight the current text block"""
88 if not self.enabled or not text:
89 return
90 formats = []
91 state = self.get_next_state(text)
92 if state == self.DIFFSTAT_STATE:
93 state, formats = self.get_formats_for_diffstat(state, text)
94 elif state == self.DIFF_FILE_HEADER_STATE:
95 state, formats = self.get_formats_for_diff_header(state, text)
96 elif state == self.DIFF_STATE:
97 state, formats = self.get_formats_for_diff_text(state, text)
99 for start, end, fmt in formats:
100 self.setFormat(start, end, fmt)
102 self.setCurrentBlockState(state)
104 def get_next_state(self, text):
105 """Transition to the next state based on the input text"""
106 state = self.previousBlockState()
107 if state == DiffSyntaxHighlighter.INITIAL_STATE:
108 if text.startswith('Submodule '):
109 state = DiffSyntaxHighlighter.SUBMODULE_STATE
110 elif text.startswith('diff --git '):
111 state = DiffSyntaxHighlighter.DIFFSTAT_STATE
112 elif self.is_commit:
113 state = DiffSyntaxHighlighter.DEFAULT_STATE
114 else:
115 state = DiffSyntaxHighlighter.DIFFSTAT_STATE
117 return state
119 def get_formats_for_diffstat(self, state, text):
120 """Returns (state, [(start, end, fmt), ...]) for highlighting diffstat text"""
121 formats = []
122 if self.DIFF_FILE_HEADER_START_RGX.match(text):
123 state = self.DIFF_FILE_HEADER_STATE
124 end = len(text)
125 fmt = self.diff_header_fmt
126 formats.append((0, end, fmt))
127 elif self.DIFF_HUNK_HEADER_RGX.match(text):
128 state = self.DIFF_STATE
129 end = len(text)
130 fmt = self.bold_diff_header_fmt
131 formats.append((0, end, fmt))
132 elif '|' in text:
133 offset = text.index('|')
134 formats.append((0, offset, self.bold_diff_header_fmt))
135 formats.append((offset, len(text) - offset, self.diff_header_fmt))
136 else:
137 formats.append((0, len(text), self.diff_header_fmt))
139 return state, formats
141 def get_formats_for_diff_header(self, state, text):
142 """Returns (state, [(start, end, fmt), ...]) for highlighting diff headers"""
143 formats = []
144 if self.DIFF_HUNK_HEADER_RGX.match(text):
145 state = self.DIFF_STATE
146 formats.append((0, len(text), self.bold_diff_header_fmt))
147 else:
148 formats.append((0, len(text), self.diff_header_fmt))
150 return state, formats
152 def get_formats_for_diff_text(self, state, text):
153 """Return (state, [(start, end fmt), ...]) for highlighting diff text"""
154 formats = []
156 if self.DIFF_FILE_HEADER_START_RGX.match(text):
157 state = self.DIFF_FILE_HEADER_STATE
158 formats.append((0, len(text), self.diff_header_fmt))
160 elif self.DIFF_HUNK_HEADER_RGX.match(text):
161 formats.append((0, len(text), self.bold_diff_header_fmt))
163 elif text.startswith('-'):
164 if text == '-- ':
165 state = self.END_STATE
166 else:
167 formats.append((0, len(text), self.diff_remove_fmt))
169 elif text.startswith('+'):
170 formats.append((0, len(text), self.diff_add_fmt))
171 if self.whitespace:
172 match = self.BAD_WHITESPACE_RGX.search(text)
173 if match is not None:
174 start = match.start()
175 formats.append((start, len(text) - start, self.bad_whitespace_fmt))
177 return state, formats
180 # pylint: disable=too-many-ancestors
181 class DiffTextEdit(VimHintedPlainTextEdit):
182 """A textedit for interacting with diff text"""
184 def __init__(
185 self, context, parent, is_commit=False, whitespace=True, numbers=False
187 VimHintedPlainTextEdit.__init__(self, context, '', parent=parent)
188 # Diff/patch syntax highlighter
189 self.highlighter = DiffSyntaxHighlighter(
190 context, self.document(), is_commit=is_commit, whitespace=whitespace
192 if numbers:
193 self.numbers = DiffLineNumbers(context, self)
194 self.numbers.hide()
195 else:
196 self.numbers = None
197 self.scrollvalue = None
199 self.copy_diff_action = qtutils.add_action(
200 self,
201 N_('Copy Diff'),
202 self.copy_diff,
203 hotkeys.COPY_DIFF,
205 self.copy_diff_action.setIcon(icons.copy())
206 self.copy_diff_action.setEnabled(False)
207 self.menu_actions.append(self.copy_diff_action)
209 # pylint: disable=no-member
210 self.cursorPositionChanged.connect(self._cursor_changed, Qt.QueuedConnection)
211 self.selectionChanged.connect(self._selection_changed, Qt.QueuedConnection)
213 def setFont(self, font):
214 """Override setFont() so that we can use a custom "block" cursor"""
215 super(DiffTextEdit, self).setFont(font)
216 if prefs.block_cursor(self.context):
217 metrics = QtGui.QFontMetrics(font)
218 width = metrics.width('M')
219 self.setCursorWidth(width)
221 def _cursor_changed(self):
222 """Update the line number display when the cursor changes"""
223 line_number = max(0, self.textCursor().blockNumber())
224 if self.numbers:
225 self.numbers.set_highlighted(line_number)
227 def _selection_changed(self):
228 """Respond to selection changes"""
229 selected = bool(self.selected_text())
230 self.copy_diff_action.setEnabled(selected)
232 def resizeEvent(self, event):
233 super(DiffTextEdit, self).resizeEvent(event)
234 if self.numbers:
235 self.numbers.refresh_size()
237 def save_scrollbar(self):
238 """Save the scrollbar value, but only on the first call"""
239 if self.scrollvalue is None:
240 scrollbar = self.verticalScrollBar()
241 if scrollbar:
242 scrollvalue = get(scrollbar)
243 else:
244 scrollvalue = None
245 self.scrollvalue = scrollvalue
247 def restore_scrollbar(self):
248 """Restore the scrollbar and clear state"""
249 scrollbar = self.verticalScrollBar()
250 scrollvalue = self.scrollvalue
251 if scrollbar and scrollvalue is not None:
252 scrollbar.setValue(scrollvalue)
253 self.scrollvalue = None
255 def set_loading_message(self):
256 """Add a pending loading message in the diff view"""
257 self.hint.set_value('+++ ' + N_('Loading...'))
258 self.set_value('')
260 def set_diff(self, diff):
261 """Set the diff text, but save the scrollbar"""
262 diff = diff.rstrip('\n') # diffs include two empty newlines
263 self.save_scrollbar()
265 self.hint.set_value('')
266 if self.numbers:
267 self.numbers.set_diff(diff)
268 self.set_value(diff)
270 self.restore_scrollbar()
272 def selected_diff_stripped(self):
273 """Return the selected diff stripped of any diff characters"""
274 sep, selection = self.selected_text_lines()
275 return sep.join(_strip_diff(line) for line in selection)
277 def copy_diff(self):
278 """Copy the selected diff text stripped of any diff prefix characters"""
279 text = self.selected_diff_stripped()
280 qtutils.set_clipboard(text)
282 def selected_lines(self):
283 """Return selected lines"""
284 cursor = self.textCursor()
285 selection_start = cursor.selectionStart()
286 selection_end = max(selection_start, cursor.selectionEnd() - 1)
288 first_line_idx = -1
289 last_line_idx = -1
290 line_idx = 0
291 line_start = 0
293 for line_idx, line in enumerate(get(self, default='').splitlines()):
294 line_end = line_start + len(line)
295 if line_start <= selection_start <= line_end:
296 first_line_idx = line_idx
297 if line_start <= selection_end <= line_end:
298 last_line_idx = line_idx
299 break
300 line_start = line_end + 1
302 if first_line_idx == -1:
303 first_line_idx = line_idx
305 if last_line_idx == -1:
306 last_line_idx = line_idx
308 return first_line_idx, last_line_idx
310 def selected_text_lines(self):
311 """Return selected lines and the CRLF / LF separator"""
312 first_line_idx, last_line_idx = self.selected_lines()
313 text = get(self, default='')
314 sep = _get_sep(text)
315 lines = []
316 for line_idx, line in enumerate(text.split(sep)):
317 if first_line_idx <= line_idx <= last_line_idx:
318 lines.append(line)
319 return sep, lines
322 def _get_sep(text):
323 """Return either CRLF or LF based on the content"""
324 if '\r\n' in text:
325 sep = '\r\n'
326 else:
327 sep = '\n'
328 return sep
331 def _strip_diff(value):
332 """Remove +/-/<space> from a selection"""
333 if value.startswith(('+', '-', ' ')):
334 return value[1:]
335 return value
338 class DiffLineNumbers(TextDecorator):
339 def __init__(self, context, parent):
340 TextDecorator.__init__(self, parent)
341 self.highlight_line = -1
342 self.lines = None
343 self.parser = diffparse.DiffLines()
344 self.formatter = diffparse.FormatDigits()
346 self.setFont(qtutils.diff_font(context))
347 self._char_width = self.fontMetrics().width('0')
349 QPalette = QtGui.QPalette
350 self._palette = palette = self.palette()
351 self._base = palette.color(QtGui.QPalette.Base)
352 self._highlight = palette.color(QPalette.Highlight)
353 self._highlight.setAlphaF(0.3)
354 self._highlight_text = palette.color(QPalette.HighlightedText)
355 self._window = palette.color(QPalette.Window)
356 self._disabled = palette.color(QPalette.Disabled, QPalette.Text)
358 def set_diff(self, diff):
359 self.lines = self.parser.parse(diff)
360 self.formatter.set_digits(self.parser.digits())
362 def width_hint(self):
363 if not self.isVisible():
364 return 0
365 parser = self.parser
367 if parser.merge:
368 columns = 3
369 extra = 3 # one space in-between, one space after
370 else:
371 columns = 2
372 extra = 2 # one space in-between, one space after
374 digits = parser.digits() * columns
376 return defs.margin + (self._char_width * (digits + extra))
378 def set_highlighted(self, line_number):
379 """Set the line to highlight"""
380 self.highlight_line = line_number
382 def current_line(self):
383 lines = self.lines
384 if lines and self.highlight_line >= 0:
385 # Find the next valid line
386 for i in range(self.highlight_line, len(lines)):
387 # take the "new" line number: last value in tuple
388 line_number = lines[i][-1]
389 if line_number > 0:
390 return line_number
392 # Find the previous valid line
393 for i in range(self.highlight_line - 1, -1, -1):
394 # take the "new" line number: last value in tuple
395 if i < len(lines):
396 line_number = lines[i][-1]
397 if line_number > 0:
398 return line_number
399 return None
401 def paintEvent(self, event):
402 """Paint the line number"""
403 if not self.lines:
404 return
406 painter = QtGui.QPainter(self)
407 painter.fillRect(event.rect(), self._base)
409 editor = self.editor
410 content_offset = editor.contentOffset()
411 block = editor.firstVisibleBlock()
412 width = self.width()
413 text_width = width - (defs.margin * 2)
414 text_flags = Qt.AlignRight | Qt.AlignVCenter
415 event_rect_bottom = event.rect().bottom()
417 highlight_line = self.highlight_line
418 highlight = self._highlight
419 highlight_text = self._highlight_text
420 disabled = self._disabled
422 fmt = self.formatter
423 lines = self.lines
424 num_lines = len(lines)
426 while block.isValid():
427 block_number = block.blockNumber()
428 if block_number >= num_lines:
429 break
430 block_geom = editor.blockBoundingGeometry(block)
431 rect = block_geom.translated(content_offset).toRect()
432 if not block.isVisible() or rect.top() >= event_rect_bottom:
433 break
435 if block_number == highlight_line:
436 painter.fillRect(rect.x(), rect.y(), width, rect.height(), highlight)
437 painter.setPen(highlight_text)
438 else:
439 painter.setPen(disabled)
441 line = lines[block_number]
442 if len(line) == 2:
443 a, b = line
444 text = fmt.value(a, b)
445 elif len(line) == 3:
446 old, base, new = line
447 text = fmt.merge_value(old, base, new)
449 painter.drawText(
450 rect.x(),
451 rect.y(),
452 text_width,
453 rect.height(),
454 text_flags,
455 text,
458 block = block.next() # pylint: disable=next-method-called
461 class Viewer(QtWidgets.QFrame):
462 """Text and image diff viewers"""
463 INDEX_TEXT = 0
464 INDEX_IMAGE = 1
466 def __init__(self, context, parent=None):
467 super(Viewer, self).__init__(parent)
469 self.context = context
470 self.model = model = context.model
471 self.images = []
472 self.pixmaps = []
473 self.options = options = Options(self)
474 self.text = DiffEditor(context, options, self)
475 self.image = imageview.ImageView(parent=self)
476 self.image.setFocusPolicy(Qt.NoFocus)
477 self.search_widget = TextSearchWidget(self)
478 self.search_widget.hide()
480 stack = self.stack = QtWidgets.QStackedWidget(self)
481 stack.addWidget(self.text)
482 stack.addWidget(self.image)
484 self.main_layout = qtutils.vbox(
485 defs.no_margin,
486 defs.no_spacing,
487 self.stack,
488 self.search_widget,
490 self.setLayout(self.main_layout)
492 # Observe images
493 model.images_changed.connect(self.set_images, type=Qt.QueuedConnection)
495 # Observe the diff type
496 model.diff_type_changed.connect(self.set_diff_type, type=Qt.QueuedConnection)
498 # Observe the file type
499 model.file_type_changed.connect(self.set_file_type, type=Qt.QueuedConnection)
501 # Observe the image mode combo box
502 options.image_mode.currentIndexChanged.connect(lambda _: self.render())
503 options.zoom_mode.currentIndexChanged.connect(lambda _: self.render())
505 self.setFocusProxy(self.text)
507 self.search_widget.search_text.connect(self.search_text)
509 self.search_action = qtutils.add_action(
510 self,
511 N_('Search in Diff'),
512 self.show_search_diff,
513 hotkeys.SEARCH,
515 self.search_next_action = qtutils.add_action(
516 self,
517 N_('Find next item'),
518 self.search_widget.search,
519 hotkeys.SEARCH_NEXT,
521 self.search_prev_action = qtutils.add_action(
522 self,
523 N_('Find previous item'),
524 self.search_widget.search_backwards,
525 hotkeys.SEARCH_PREV,
528 def show_search_diff(self):
529 """Show a dialog for searching diffs"""
530 # The diff search is only active in text mode.
531 if self.stack.currentIndex() != self.INDEX_TEXT:
532 return
533 if not self.search_widget.isVisible():
534 self.search_widget.show()
535 self.search_widget.setFocus(True)
537 def search_text(self, text, backwards):
538 """Search the diff text for the given text"""
539 cursor = self.text.textCursor()
540 if cursor.hasSelection():
541 selected_text = cursor.selectedText()
542 case_sensitive = self.search_widget.is_case_sensitive()
543 if text_matches(case_sensitive, selected_text, text):
544 if backwards:
545 position = cursor.selectionStart()
546 else:
547 position = cursor.selectionEnd()
548 else:
549 if backwards:
550 position = cursor.selectionEnd()
551 else:
552 position = cursor.selectionStart()
553 cursor.setPosition(position)
554 self.text.setTextCursor(cursor)
556 flags = self.search_widget.find_flags(backwards)
557 if not self.text.find(text, flags):
558 if backwards:
559 location = QtGui.QTextCursor.End
560 else:
561 location = QtGui.QTextCursor.Start
562 cursor.movePosition(location, QtGui.QTextCursor.MoveAnchor)
563 self.text.setTextCursor(cursor)
564 self.text.find(text, flags)
566 def export_state(self, state):
567 state['show_diff_line_numbers'] = self.options.show_line_numbers.isChecked()
568 state['image_diff_mode'] = self.options.image_mode.currentIndex()
569 state['image_zoom_mode'] = self.options.zoom_mode.currentIndex()
570 state['word_wrap'] = self.options.enable_word_wrapping.isChecked()
571 return state
573 def apply_state(self, state):
574 diff_numbers = bool(state.get('show_diff_line_numbers', False))
575 self.set_line_numbers(diff_numbers, update=True)
577 image_mode = utils.asint(state.get('image_diff_mode', 0))
578 self.options.image_mode.set_index(image_mode)
580 zoom_mode = utils.asint(state.get('image_zoom_mode', 0))
581 self.options.zoom_mode.set_index(zoom_mode)
583 word_wrap = bool(state.get('word_wrap', True))
584 self.set_word_wrapping(word_wrap, update=True)
585 return True
587 def set_diff_type(self, diff_type):
588 """Manage the image and text diff views when selection changes"""
589 # The "diff type" is whether the diff viewer is displaying an image.
590 self.options.set_diff_type(diff_type)
591 if diff_type == main.Types.IMAGE:
592 self.stack.setCurrentWidget(self.image)
593 self.search_widget.hide()
594 self.render()
595 else:
596 self.stack.setCurrentWidget(self.text)
598 def set_file_type(self, file_type):
599 """Manage the diff options when the file type changes"""
600 # The "file type" is whether the file itself is an image.
601 self.options.set_file_type(file_type)
603 def update_options(self):
604 """Emit a signal indicating that options have changed"""
605 self.text.update_options()
607 def set_line_numbers(self, enabled, update=False):
608 """Enable/disable line numbers in the text widget"""
609 self.text.set_line_numbers(enabled, update=update)
611 def set_word_wrapping(self, enabled, update=False):
612 """Enable/disable word wrapping in the text widget"""
613 self.text.set_word_wrapping(enabled, update=update)
615 def reset(self):
616 self.image.pixmap = QtGui.QPixmap()
617 self.cleanup()
619 def cleanup(self):
620 for (image, unlink) in self.images:
621 if unlink and core.exists(image):
622 os.unlink(image)
623 self.images = []
625 def set_images(self, images):
626 self.images = images
627 self.pixmaps = []
628 if not images:
629 self.reset()
630 return False
632 # In order to comp, we first have to load all the images
633 all_pixmaps = [QtGui.QPixmap(image[0]) for image in images]
634 pixmaps = [pixmap for pixmap in all_pixmaps if not pixmap.isNull()]
635 if not pixmaps:
636 self.reset()
637 return False
639 self.pixmaps = pixmaps
640 self.render()
641 self.cleanup()
642 return True
644 def render(self):
645 # Update images
646 if self.pixmaps:
647 mode = self.options.image_mode.currentIndex()
648 if mode == self.options.SIDE_BY_SIDE:
649 image = self.render_side_by_side()
650 elif mode == self.options.DIFF:
651 image = self.render_diff()
652 elif mode == self.options.XOR:
653 image = self.render_xor()
654 elif mode == self.options.PIXEL_XOR:
655 image = self.render_pixel_xor()
656 else:
657 image = self.render_side_by_side()
658 else:
659 image = QtGui.QPixmap()
660 self.image.pixmap = image
662 # Apply zoom
663 zoom_mode = self.options.zoom_mode.currentIndex()
664 zoom_factor = self.options.zoom_factors[zoom_mode][1]
665 if zoom_factor > 0.0:
666 self.image.resetTransform()
667 self.image.scale(zoom_factor, zoom_factor)
668 poly = self.image.mapToScene(self.image.viewport().rect())
669 self.image.last_scene_roi = poly.boundingRect()
671 def render_side_by_side(self):
672 # Side-by-side lineup comp
673 pixmaps = self.pixmaps
674 width = sum(pixmap.width() for pixmap in pixmaps)
675 height = max(pixmap.height() for pixmap in pixmaps)
676 image = create_image(width, height)
678 # Paint each pixmap
679 painter = create_painter(image)
680 x = 0
681 for pixmap in pixmaps:
682 painter.drawPixmap(x, 0, pixmap)
683 x += pixmap.width()
684 painter.end()
686 return image
688 def render_comp(self, comp_mode):
689 # Get the max size to use as the render canvas
690 pixmaps = self.pixmaps
691 if len(pixmaps) == 1:
692 return pixmaps[0]
694 width = max(pixmap.width() for pixmap in pixmaps)
695 height = max(pixmap.height() for pixmap in pixmaps)
696 image = create_image(width, height)
698 painter = create_painter(image)
699 for pixmap in (pixmaps[0], pixmaps[-1]):
700 x = (width - pixmap.width()) // 2
701 y = (height - pixmap.height()) // 2
702 painter.drawPixmap(x, y, pixmap)
703 painter.setCompositionMode(comp_mode)
704 painter.end()
706 return image
708 def render_diff(self):
709 comp_mode = QtGui.QPainter.CompositionMode_Difference
710 return self.render_comp(comp_mode)
712 def render_xor(self):
713 comp_mode = QtGui.QPainter.CompositionMode_Xor
714 return self.render_comp(comp_mode)
716 def render_pixel_xor(self):
717 comp_mode = QtGui.QPainter.RasterOp_SourceXorDestination
718 return self.render_comp(comp_mode)
721 def create_image(width, height):
722 size = QtCore.QSize(width, height)
723 image = QtGui.QImage(size, QtGui.QImage.Format_ARGB32_Premultiplied)
724 image.fill(Qt.transparent)
725 return image
728 def text_matches(case_sensitive, a, b):
729 """Compare text with case sensitivity taken into account"""
730 if case_sensitive:
731 return a == b
732 return a.lower() == b.lower()
735 def create_painter(image):
736 painter = QtGui.QPainter(image)
737 painter.fillRect(image.rect(), Qt.transparent)
738 return painter
741 class Options(QtWidgets.QWidget):
742 """Provide the options widget used by the editor
744 Actions are registered on the parent widget.
748 # mode combobox indexes
749 SIDE_BY_SIDE = 0
750 DIFF = 1
751 XOR = 2
752 PIXEL_XOR = 3
754 def __init__(self, parent):
755 super(Options, self).__init__(parent)
756 # Create widgets
757 self.widget = parent
758 self.ignore_space_at_eol = self.add_option(
759 N_('Ignore changes in whitespace at EOL')
762 self.ignore_space_change = self.add_option(
763 N_('Ignore changes in amount of whitespace')
766 self.ignore_all_space = self.add_option(N_('Ignore all whitespace'))
768 self.function_context = self.add_option(
769 N_('Show whole surrounding functions of changes')
772 self.show_line_numbers = qtutils.add_action_bool(
773 self, N_('Show line numbers'), self.set_line_numbers, True
775 self.enable_word_wrapping = qtutils.add_action_bool(
776 self, N_('Enable word wrapping'), self.set_word_wrapping, True
779 self.options = qtutils.create_action_button(
780 tooltip=N_('Diff Options'), icon=icons.configure()
783 self.toggle_image_diff = qtutils.create_action_button(
784 tooltip=N_('Toggle image diff'), icon=icons.visualize()
786 self.toggle_image_diff.hide()
788 self.image_mode = qtutils.combo(
789 [N_('Side by side'), N_('Diff'), N_('XOR'), N_('Pixel XOR')]
792 self.zoom_factors = (
793 (N_('Zoom to Fit'), 0.0),
794 (N_('25%'), 0.25),
795 (N_('50%'), 0.5),
796 (N_('100%'), 1.0),
797 (N_('200%'), 2.0),
798 (N_('400%'), 4.0),
799 (N_('800%'), 8.0),
801 zoom_modes = [factor[0] for factor in self.zoom_factors]
802 self.zoom_mode = qtutils.combo(zoom_modes, parent=self)
804 self.menu = menu = qtutils.create_menu(N_('Diff Options'), self.options)
805 self.options.setMenu(menu)
806 menu.addAction(self.ignore_space_at_eol)
807 menu.addAction(self.ignore_space_change)
808 menu.addAction(self.ignore_all_space)
809 menu.addSeparator()
810 menu.addAction(self.function_context)
811 menu.addAction(self.show_line_numbers)
812 menu.addSeparator()
813 menu.addAction(self.enable_word_wrapping)
815 # Layouts
816 layout = qtutils.hbox(
817 defs.no_margin,
818 defs.button_spacing,
819 self.image_mode,
820 self.zoom_mode,
821 self.options,
822 self.toggle_image_diff,
824 self.setLayout(layout)
826 # Policies
827 self.image_mode.setFocusPolicy(Qt.NoFocus)
828 self.zoom_mode.setFocusPolicy(Qt.NoFocus)
829 self.options.setFocusPolicy(Qt.NoFocus)
830 self.toggle_image_diff.setFocusPolicy(Qt.NoFocus)
831 self.setFocusPolicy(Qt.NoFocus)
833 def set_file_type(self, file_type):
834 """Set whether we are viewing an image file type"""
835 is_image = file_type == main.Types.IMAGE
836 self.toggle_image_diff.setVisible(is_image)
838 def set_diff_type(self, diff_type):
839 """Toggle between image and text diffs"""
840 is_text = diff_type == main.Types.TEXT
841 is_image = diff_type == main.Types.IMAGE
842 self.options.setVisible(is_text)
843 self.image_mode.setVisible(is_image)
844 self.zoom_mode.setVisible(is_image)
845 if is_image:
846 self.toggle_image_diff.setIcon(icons.diff())
847 else:
848 self.toggle_image_diff.setIcon(icons.visualize())
850 def add_option(self, title):
851 """Add a diff option which calls update_options() on change"""
852 action = qtutils.add_action(self, title, self.update_options)
853 action.setCheckable(True)
854 return action
856 def update_options(self):
857 """Update diff options in response to UI events"""
858 space_at_eol = get(self.ignore_space_at_eol)
859 space_change = get(self.ignore_space_change)
860 all_space = get(self.ignore_all_space)
861 function_context = get(self.function_context)
862 gitcmds.update_diff_overrides(
863 space_at_eol, space_change, all_space, function_context
865 self.widget.update_options()
867 def set_line_numbers(self, value):
868 """Enable / disable line numbers"""
869 self.widget.set_line_numbers(value, update=False)
871 def set_word_wrapping(self, value):
872 """Respond to Qt action callbacks"""
873 self.widget.set_word_wrapping(value, update=False)
875 def hide_advanced_options(self):
876 """Hide advanced options that are not applicable to the DiffWidget"""
877 self.show_line_numbers.setVisible(False)
878 self.ignore_space_at_eol.setVisible(False)
879 self.ignore_space_change.setVisible(False)
880 self.ignore_all_space.setVisible(False)
881 self.function_context.setVisible(False)
884 # pylint: disable=too-many-ancestors
885 class DiffEditor(DiffTextEdit):
887 up = Signal()
888 down = Signal()
889 options_changed = Signal()
891 def __init__(self, context, options, parent):
892 DiffTextEdit.__init__(self, context, parent, numbers=True)
893 self.context = context
894 self.model = model = context.model
895 self.selection_model = selection_model = context.selection
897 # "Diff Options" tool menu
898 self.options = options
900 self.action_apply_selection = qtutils.add_action(
901 self,
902 'Apply',
903 self.apply_selection,
904 hotkeys.STAGE_DIFF,
905 hotkeys.STAGE_DIFF_ALT,
908 self.action_revert_selection = qtutils.add_action(
909 self, 'Revert', self.revert_selection, hotkeys.REVERT
911 self.action_revert_selection.setIcon(icons.undo())
913 self.action_edit_and_apply_selection = qtutils.add_action(
914 self,
915 'Edit and Apply',
916 partial(self.apply_selection, edit=True),
917 hotkeys.EDIT_AND_STAGE_DIFF,
920 self.action_edit_and_revert_selection = qtutils.add_action(
921 self,
922 'Edit and Revert',
923 partial(self.revert_selection, edit=True),
924 hotkeys.EDIT_AND_REVERT,
926 self.action_edit_and_revert_selection.setIcon(icons.undo())
927 self.launch_editor = actions.launch_editor_at_line(
928 context, self, hotkeys.EDIT_SHORT, *hotkeys.ACCEPT
930 self.launch_difftool = actions.launch_difftool(context, self)
931 self.stage_or_unstage = actions.stage_or_unstage(context, self)
933 # Emit up/down signals so that they can be routed by the main widget
934 self.move_up = actions.move_up(self)
935 self.move_down = actions.move_down(self)
937 model.diff_text_updated.connect(self.set_diff, type=Qt.QueuedConnection)
939 selection_model.selection_changed.connect(
940 self.refresh, type=Qt.QueuedConnection
942 # Update the selection model when the cursor changes
943 self.cursorPositionChanged.connect(self._update_line_number)
945 qtutils.connect_button(options.toggle_image_diff, self.toggle_diff_type)
947 def toggle_diff_type(self):
948 cmds.do(cmds.ToggleDiffType, self.context)
950 def refresh(self):
951 enabled = False
952 s = self.selection_model.selection()
953 model = self.model
954 if model.is_partially_stageable():
955 item = s.modified[0] if s.modified else None
956 if item in model.submodules:
957 pass
958 elif item not in model.unstaged_deleted:
959 enabled = True
960 self.action_revert_selection.setEnabled(enabled)
962 def set_line_numbers(self, enabled, update=False):
963 """Enable/disable the diff line number display"""
964 self.numbers.setVisible(enabled)
965 if update:
966 with qtutils.BlockSignals(self.options.show_line_numbers):
967 self.options.show_line_numbers.setChecked(enabled)
968 # Refresh the display. Not doing this results in the display not
969 # correctly displaying the line numbers widget until the text scrolls.
970 self.set_value(self.value())
972 def update_options(self):
973 self.options_changed.emit()
975 def create_context_menu(self, event_pos):
976 """Override create_context_menu() to display a completely custom menu"""
977 menu = super(DiffEditor, self).create_context_menu(event_pos)
978 context = self.context
979 model = self.model
980 s = self.selection_model.selection()
981 filename = self.selection_model.filename()
983 # These menu actions will be inserted at the start of the widget.
984 current_actions = menu.actions()
985 menu_actions = []
986 add_action = menu_actions.append
988 if model.is_stageable() or model.is_unstageable():
989 if model.is_stageable():
990 self.stage_or_unstage.setText(N_('Stage'))
991 self.stage_or_unstage.setIcon(icons.add())
992 else:
993 self.stage_or_unstage.setText(N_('Unstage'))
994 self.stage_or_unstage.setIcon(icons.remove())
995 add_action(self.stage_or_unstage)
997 if model.is_partially_stageable():
998 item = s.modified[0] if s.modified else None
999 if item in model.submodules:
1000 path = core.abspath(item)
1001 action = qtutils.add_action_with_icon(
1002 menu,
1003 icons.add(),
1004 cmds.Stage.name(),
1005 cmds.run(cmds.Stage, context, s.modified),
1006 hotkeys.STAGE_SELECTION,
1008 add_action(action)
1010 action = qtutils.add_action_with_icon(
1011 menu,
1012 icons.cola(),
1013 N_('Launch git-cola'),
1014 cmds.run(cmds.OpenRepo, context, path),
1016 add_action(action)
1017 elif item not in model.unstaged_deleted:
1018 if self.has_selection():
1019 apply_text = N_('Stage Selected Lines')
1020 edit_and_apply_text = N_('Edit Selected Lines to Stage...')
1021 revert_text = N_('Revert Selected Lines...')
1022 edit_and_revert_text = N_('Edit Selected Lines to Revert...')
1023 else:
1024 apply_text = N_('Stage Diff Hunk')
1025 edit_and_apply_text = N_('Edit Diff Hunk to Stage...')
1026 revert_text = N_('Revert Diff Hunk...')
1027 edit_and_revert_text = N_('Edit Diff Hunk to Revert...')
1029 self.action_apply_selection.setText(apply_text)
1030 self.action_apply_selection.setIcon(icons.add())
1031 add_action(self.action_apply_selection)
1033 self.action_edit_and_apply_selection.setText(edit_and_apply_text)
1034 self.action_edit_and_apply_selection.setIcon(icons.add())
1035 add_action(self.action_edit_and_apply_selection)
1037 self.action_revert_selection.setText(revert_text)
1038 add_action(self.action_revert_selection)
1040 self.action_edit_and_revert_selection.setText(edit_and_revert_text)
1041 add_action(self.action_edit_and_revert_selection)
1043 if s.staged and model.is_unstageable():
1044 item = s.staged[0]
1045 if item in model.submodules:
1046 path = core.abspath(item)
1047 action = qtutils.add_action_with_icon(
1048 menu,
1049 icons.remove(),
1050 cmds.Unstage.name(),
1051 cmds.run(cmds.Unstage, context, s.staged),
1052 hotkeys.STAGE_SELECTION,
1054 add_action(action)
1056 qtutils.add_action_with_icon(
1057 menu,
1058 icons.cola(),
1059 N_('Launch git-cola'),
1060 cmds.run(cmds.OpenRepo, context, path),
1062 add_action(action)
1064 elif item not in model.staged_deleted:
1065 if self.has_selection():
1066 apply_text = N_('Unstage Selected Lines')
1067 edit_and_apply_text = N_('Edit Selected Lines to Unstage...')
1068 else:
1069 apply_text = N_('Unstage Diff Hunk')
1070 edit_and_apply_text = N_('Edit Diff Hunk to Unstage...')
1072 self.action_apply_selection.setText(apply_text)
1073 self.action_apply_selection.setIcon(icons.remove())
1074 add_action(self.action_apply_selection)
1076 self.action_edit_and_apply_selection.setText(edit_and_apply_text)
1077 self.action_edit_and_apply_selection.setIcon(icons.remove())
1078 add_action(self.action_edit_and_apply_selection)
1080 if model.is_stageable() or model.is_unstageable():
1081 # Do not show the "edit" action when the file does not exist.
1082 # Untracked files exist by definition.
1083 if filename and core.exists(filename):
1084 add_action(qtutils.menu_separator(menu))
1085 add_action(self.launch_editor)
1087 # Removed files can still be diffed.
1088 add_action(self.launch_difftool)
1090 # Add the Previous/Next File actions, which improves discoverability
1091 # of their associated shortcuts
1092 add_action(qtutils.menu_separator(menu))
1093 add_action(self.move_up)
1094 add_action(self.move_down)
1095 add_action(qtutils.menu_separator(menu))
1097 if current_actions:
1098 first_action = current_actions[0]
1099 else:
1100 first_action = None
1101 menu.insertActions(first_action, menu_actions)
1103 return menu
1105 def mousePressEvent(self, event):
1106 if event.button() == Qt.RightButton:
1107 # Intercept right-click to move the cursor to the current position.
1108 # setTextCursor() clears the selection so this is only done when
1109 # nothing is selected.
1110 if not self.has_selection():
1111 cursor = self.cursorForPosition(event.pos())
1112 self.setTextCursor(cursor)
1114 return super(DiffEditor, self).mousePressEvent(event)
1116 def setPlainText(self, text):
1117 """setPlainText(str) while retaining scrollbar positions"""
1118 model = self.model
1119 mode = model.mode
1120 highlight = mode not in (
1121 model.mode_none,
1122 model.mode_display,
1123 model.mode_untracked,
1125 self.highlighter.set_enabled(highlight)
1127 scrollbar = self.verticalScrollBar()
1128 if scrollbar:
1129 scrollvalue = get(scrollbar)
1130 else:
1131 scrollvalue = None
1133 if text is None:
1134 return
1136 DiffTextEdit.setPlainText(self, text)
1138 if scrollbar and scrollvalue is not None:
1139 scrollbar.setValue(scrollvalue)
1141 def apply_selection(self, *, edit=False):
1142 model = self.model
1143 s = self.selection_model.single_selection()
1144 if model.is_partially_stageable() and (s.modified or s.untracked):
1145 self.process_diff_selection(edit=edit)
1146 elif model.is_unstageable():
1147 self.process_diff_selection(reverse=True, edit=edit)
1149 def revert_selection(self, *, edit=False):
1150 """Destructively revert selected lines or hunk from a worktree file."""
1152 if not edit:
1153 if self.has_selection():
1154 title = N_('Revert Selected Lines?')
1155 ok_text = N_('Revert Selected Lines')
1156 else:
1157 title = N_('Revert Diff Hunk?')
1158 ok_text = N_('Revert Diff Hunk')
1160 if not Interaction.confirm(
1161 title,
1163 'This operation drops uncommitted changes.\n'
1164 'These changes cannot be recovered.'
1166 N_('Revert the uncommitted changes?'),
1167 ok_text,
1168 default=True,
1169 icon=icons.undo(),
1171 return
1172 self.process_diff_selection(reverse=True, apply_to_worktree=True, edit=edit)
1174 def extract_patch(self, reverse=False):
1175 first_line_idx, last_line_idx = self.selected_lines()
1176 patch = diffparse.Patch.parse(self.model.filename, self.model.diff_text)
1177 if self.has_selection():
1178 return patch.extract_subset(first_line_idx, last_line_idx, reverse=reverse)
1179 return patch.extract_hunk(first_line_idx, reverse=reverse)
1181 def patch_encoding(self):
1182 if isinstance(self.model.diff_text, core.UStr):
1183 # original encoding must prevail
1184 return self.model.diff_text.encoding
1185 return self.context.cfg.file_encoding(self.model.filename)
1187 def process_diff_selection(
1188 self, reverse=False, apply_to_worktree=False, edit=False
1190 """Implement un/staging of the selected line(s) or hunk."""
1191 if self.selection_model.is_empty():
1192 return
1193 patch = self.extract_patch(reverse)
1194 if not patch.has_changes():
1195 return
1196 patch_encoding = self.patch_encoding()
1198 if edit:
1199 patch = edit_patch(
1200 patch,
1201 patch_encoding,
1202 self.context,
1203 reverse=reverse,
1204 apply_to_worktree=apply_to_worktree,
1206 if not patch.has_changes():
1207 return
1209 cmds.do(
1210 cmds.ApplyPatch,
1211 self.context,
1212 patch,
1213 patch_encoding,
1214 apply_to_worktree,
1217 def _update_line_number(self):
1218 """Update the selection model when the cursor changes"""
1219 self.selection_model.line_number = self.numbers.current_line()
1222 class DiffWidget(QtWidgets.QWidget):
1223 """Display commit metadata and text diffs"""
1225 def __init__(self, context, parent, is_commit=False, options=None):
1226 QtWidgets.QWidget.__init__(self, parent)
1228 self.context = context
1229 self.oid = 'HEAD'
1230 self.oid_start = None
1231 self.oid_end = None
1232 self.options = options
1234 author_font = QtGui.QFont(self.font())
1235 author_font.setPointSize(int(author_font.pointSize() * 1.1))
1237 summary_font = QtGui.QFont(author_font)
1238 summary_font.setWeight(QtGui.QFont.Bold)
1240 policy = QtWidgets.QSizePolicy(
1241 QtWidgets.QSizePolicy.MinimumExpanding, QtWidgets.QSizePolicy.Minimum
1244 self.gravatar_label = gravatar.GravatarLabel(self.context, parent=self)
1246 self.author_label = TextLabel()
1247 self.author_label.setTextFormat(Qt.RichText)
1248 self.author_label.setFont(author_font)
1249 self.author_label.setSizePolicy(policy)
1250 self.author_label.setAlignment(Qt.AlignBottom)
1251 self.author_label.elide()
1253 self.date_label = TextLabel()
1254 self.date_label.setTextFormat(Qt.PlainText)
1255 self.date_label.setSizePolicy(policy)
1256 self.date_label.setAlignment(Qt.AlignTop)
1257 self.date_label.hide()
1259 self.summary_label = TextLabel()
1260 self.summary_label.setTextFormat(Qt.PlainText)
1261 self.summary_label.setFont(summary_font)
1262 self.summary_label.setSizePolicy(policy)
1263 self.summary_label.setAlignment(Qt.AlignTop)
1264 self.summary_label.elide()
1266 self.oid_label = TextLabel()
1267 self.oid_label.setTextFormat(Qt.PlainText)
1268 self.oid_label.setSizePolicy(policy)
1269 self.oid_label.setAlignment(Qt.AlignTop)
1270 self.oid_label.elide()
1272 self.diff = DiffTextEdit(context, self, is_commit=is_commit, whitespace=False)
1273 self.setFocusProxy(self.diff)
1275 self.info_layout = qtutils.vbox(
1276 defs.no_margin,
1277 defs.no_spacing,
1278 self.author_label,
1279 self.date_label,
1280 self.summary_label,
1281 self.oid_label,
1284 self.logo_layout = qtutils.hbox(
1285 defs.no_margin, defs.button_spacing, self.gravatar_label, self.info_layout
1287 self.logo_layout.setContentsMargins(defs.margin, 0, defs.margin, 0)
1289 self.main_layout = qtutils.vbox(
1290 defs.no_margin, defs.spacing, self.logo_layout, self.diff
1292 self.setLayout(self.main_layout)
1294 self.set_tabwidth(prefs.tabwidth(context))
1296 def set_tabwidth(self, width):
1297 self.diff.set_tabwidth(width)
1299 def set_word_wrapping(self, enabled, update=False):
1300 """Enable and disable word wrapping"""
1301 self.diff.set_word_wrapping(enabled, update=update)
1303 def set_options(self, options):
1304 """Register an options widget"""
1305 self.options = options
1306 self.diff.set_options(options)
1308 def start_diff_task(self, task):
1309 """Clear the display and start a diff-gathering task"""
1310 self.diff.save_scrollbar()
1311 self.diff.set_loading_message()
1312 self.context.runtask.start(task, result=self.set_diff)
1314 def set_diff_oid(self, oid, filename=None):
1315 """Set the diff from a single commit object ID"""
1316 task = DiffInfoTask(self.context, oid, filename)
1317 self.start_diff_task(task)
1319 def set_diff_range(self, start, end, filename=None):
1320 task = DiffRangeTask(self.context, start + '~', end, filename)
1321 self.start_diff_task(task)
1323 def commits_selected(self, commits):
1324 """Display an appropriate diff when commits are selected"""
1325 if not commits:
1326 self.clear()
1327 return
1328 commit = commits[-1]
1329 oid = commit.oid
1330 email = commit.email or ''
1331 summary = commit.summary or ''
1332 author = commit.author or ''
1333 self.set_details(oid, author, email, '', summary)
1334 self.oid = oid
1336 if len(commits) > 1:
1337 start, end = commits[0], commits[-1]
1338 self.set_diff_range(start.oid, end.oid)
1339 self.oid_start = start
1340 self.oid_end = end
1341 else:
1342 self.set_diff_oid(oid)
1343 self.oid_start = None
1344 self.oid_end = None
1346 def set_diff(self, diff):
1347 """Set the diff text"""
1348 self.diff.set_diff(diff)
1350 def set_details(self, oid, author, email, date, summary):
1351 template_args = {'author': author, 'email': email, 'summary': summary}
1352 author_text = (
1353 """%(author)s &lt;"""
1354 """<a href="mailto:%(email)s">"""
1355 """%(email)s</a>&gt;""" % template_args
1357 author_template = '%(author)s <%(email)s>' % template_args
1359 self.date_label.set_text(date)
1360 self.date_label.setVisible(bool(date))
1361 self.oid_label.set_text(oid)
1362 self.author_label.set_template(author_text, author_template)
1363 self.summary_label.set_text(summary)
1364 self.gravatar_label.set_email(email)
1366 def clear(self):
1367 self.date_label.set_text('')
1368 self.oid_label.set_text('')
1369 self.author_label.set_text('')
1370 self.summary_label.set_text('')
1371 self.gravatar_label.clear()
1372 self.diff.clear()
1374 def files_selected(self, filenames):
1375 """Update the view when a filename is selected"""
1376 if not filenames:
1377 return
1378 oid_start = self.oid_start
1379 oid_end = self.oid_end
1380 if oid_start and oid_end:
1381 self.set_diff_range(oid_start.oid, oid_end.oid, filename=filenames[0])
1382 else:
1383 self.set_diff_oid(self.oid, filename=filenames[0])
1386 class TextLabel(QtWidgets.QLabel):
1387 def __init__(self, parent=None):
1388 QtWidgets.QLabel.__init__(self, parent)
1389 self.setTextInteractionFlags(
1390 Qt.TextSelectableByMouse | Qt.LinksAccessibleByMouse
1392 self._display = ''
1393 self._template = ''
1394 self._text = ''
1395 self._elide = False
1396 self._metrics = QtGui.QFontMetrics(self.font())
1397 self.setOpenExternalLinks(True)
1399 def elide(self):
1400 self._elide = True
1402 def set_text(self, text):
1403 self.set_template(text, text)
1405 def set_template(self, text, template):
1406 self._display = text
1407 self._text = text
1408 self._template = template
1409 self.update_text(self.width())
1410 self.setText(self._display)
1412 def update_text(self, width):
1413 self._display = self._text
1414 if not self._elide:
1415 return
1416 text = self._metrics.elidedText(self._template, Qt.ElideRight, width - 2)
1417 if text != self._template:
1418 self._display = text
1420 # Qt overrides
1421 def setFont(self, font):
1422 self._metrics = QtGui.QFontMetrics(font)
1423 QtWidgets.QLabel.setFont(self, font)
1425 def resizeEvent(self, event):
1426 if self._elide:
1427 self.update_text(event.size().width())
1428 with qtutils.BlockSignals(self):
1429 self.setText(self._display)
1430 QtWidgets.QLabel.resizeEvent(self, event)
1433 class DiffInfoTask(qtutils.Task):
1434 """Gather diffs for a single commit"""
1436 def __init__(self, context, oid, filename):
1437 qtutils.Task.__init__(self)
1438 self.context = context
1439 self.oid = oid
1440 self.filename = filename
1442 def task(self):
1443 context = self.context
1444 oid = self.oid
1445 return gitcmds.diff_info(context, oid, filename=self.filename)
1448 class DiffRangeTask(qtutils.Task):
1449 """Gather diffs for a range of commits"""
1451 def __init__(self, context, start, end, filename):
1452 qtutils.Task.__init__(self)
1453 self.context = context
1454 self.start = start
1455 self.end = end
1456 self.filename = filename
1458 def task(self):
1459 context = self.context
1460 return gitcmds.diff_range(context, self.start, self.end, filename=self.filename)