pylint: rename variables for readability
[git-cola.git] / cola / widgets / diff.py
blob30326e5827b08078ae3417df6c59be36e94edd15
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_triple(cfg.color('text', '030303'))
67 self.color_add = qtutils.rgb_triple(
68 cfg.color('add', '77aa77' if dark else 'd2ffe4')
70 self.color_remove = qtutils.rgb_triple(
71 cfg.color('remove', 'aa7777' if dark else 'fee0e4')
73 self.color_header = qtutils.rgb_triple(cfg.color('header', header))
75 self.diff_header_fmt = qtutils.make_format(foreground=self.color_header)
76 self.bold_diff_header_fmt = qtutils.make_format(
77 foreground=self.color_header, bold=True
80 self.diff_add_fmt = qtutils.make_format(
81 foreground=self.color_text, background=self.color_add
83 self.diff_remove_fmt = qtutils.make_format(
84 foreground=self.color_text, background=self.color_remove
86 self.bad_whitespace_fmt = qtutils.make_format(background=Qt.red)
87 self.setCurrentBlockState(self.INITIAL_STATE)
89 def set_enabled(self, enabled):
90 self.enabled = enabled
92 def highlightBlock(self, text):
93 """Highlight the current text block"""
94 if not self.enabled or not text:
95 return
96 formats = []
97 state = self.get_next_state(text)
98 if state == self.DIFFSTAT_STATE:
99 state, formats = self.get_formats_for_diffstat(state, text)
100 elif state == self.DIFF_FILE_HEADER_STATE:
101 state, formats = self.get_formats_for_diff_header(state, text)
102 elif state == self.DIFF_STATE:
103 state, formats = self.get_formats_for_diff_text(state, text)
105 for start, end, fmt in formats:
106 self.setFormat(start, end, fmt)
108 self.setCurrentBlockState(state)
110 def get_next_state(self, text):
111 """Transition to the next state based on the input text"""
112 state = self.previousBlockState()
113 if state == DiffSyntaxHighlighter.INITIAL_STATE:
114 if text.startswith('Submodule '):
115 state = DiffSyntaxHighlighter.SUBMODULE_STATE
116 elif text.startswith('diff --git '):
117 state = DiffSyntaxHighlighter.DIFFSTAT_STATE
118 elif self.is_commit:
119 state = DiffSyntaxHighlighter.DEFAULT_STATE
120 else:
121 state = DiffSyntaxHighlighter.DIFFSTAT_STATE
123 return state
125 def get_formats_for_diffstat(self, state, text):
126 """Returns (state, [(start, end, fmt), ...]) for highlighting diffstat text"""
127 formats = []
128 if self.DIFF_FILE_HEADER_START_RGX.match(text):
129 state = self.DIFF_FILE_HEADER_STATE
130 end = len(text)
131 fmt = self.diff_header_fmt
132 formats.append((0, end, fmt))
133 elif self.DIFF_HUNK_HEADER_RGX.match(text):
134 state = self.DIFF_STATE
135 end = len(text)
136 fmt = self.bold_diff_header_fmt
137 formats.append((0, end, fmt))
138 elif '|' in text:
139 offset = text.index('|')
140 formats.append((0, offset, self.bold_diff_header_fmt))
141 formats.append((offset, len(text) - offset, self.diff_header_fmt))
142 else:
143 formats.append((0, len(text), self.diff_header_fmt))
145 return state, formats
147 def get_formats_for_diff_header(self, state, text):
148 """Returns (state, [(start, end, fmt), ...]) for highlighting diff headers"""
149 formats = []
150 if self.DIFF_HUNK_HEADER_RGX.match(text):
151 state = self.DIFF_STATE
152 formats.append((0, len(text), self.bold_diff_header_fmt))
153 else:
154 formats.append((0, len(text), self.diff_header_fmt))
156 return state, formats
158 def get_formats_for_diff_text(self, state, text):
159 """Return (state, [(start, end fmt), ...]) for highlighting diff text"""
160 formats = []
162 if self.DIFF_FILE_HEADER_START_RGX.match(text):
163 state = self.DIFF_FILE_HEADER_STATE
164 formats.append((0, len(text), self.diff_header_fmt))
166 elif self.DIFF_HUNK_HEADER_RGX.match(text):
167 formats.append((0, len(text), self.bold_diff_header_fmt))
169 elif text.startswith('-'):
170 if text == '-- ':
171 state = self.END_STATE
172 else:
173 formats.append((0, len(text), self.diff_remove_fmt))
175 elif text.startswith('+'):
176 formats.append((0, len(text), self.diff_add_fmt))
177 if self.whitespace:
178 match = self.BAD_WHITESPACE_RGX.search(text)
179 if match is not None:
180 start = match.start()
181 formats.append((start, len(text) - start, self.bad_whitespace_fmt))
183 return state, formats
186 # pylint: disable=too-many-ancestors
187 class DiffTextEdit(VimHintedPlainTextEdit):
188 """A textedit for interacting with diff text"""
190 def __init__(
191 self, context, parent, is_commit=False, whitespace=True, numbers=False
193 VimHintedPlainTextEdit.__init__(self, context, '', parent=parent)
194 # Diff/patch syntax highlighter
195 self.highlighter = DiffSyntaxHighlighter(
196 context, self.document(), is_commit=is_commit, whitespace=whitespace
198 if numbers:
199 self.numbers = DiffLineNumbers(context, self)
200 self.numbers.hide()
201 else:
202 self.numbers = None
203 self.scrollvalue = None
205 self.copy_diff_action = qtutils.add_action(
206 self,
207 N_('Copy Diff'),
208 self.copy_diff,
209 hotkeys.COPY_DIFF,
211 self.copy_diff_action.setIcon(icons.copy())
212 self.copy_diff_action.setEnabled(False)
213 self.menu_actions.append(self.copy_diff_action)
215 # pylint: disable=no-member
216 self.cursorPositionChanged.connect(self._cursor_changed, Qt.QueuedConnection)
217 self.selectionChanged.connect(self._selection_changed, Qt.QueuedConnection)
219 def setFont(self, font):
220 """Override setFont() so that we can use a custom "block" cursor"""
221 super(DiffTextEdit, self).setFont(font)
222 if prefs.block_cursor(self.context):
223 metrics = QtGui.QFontMetrics(font)
224 width = metrics.width('M')
225 self.setCursorWidth(width)
227 def _cursor_changed(self):
228 """Update the line number display when the cursor changes"""
229 line_number = max(0, self.textCursor().blockNumber())
230 if self.numbers:
231 self.numbers.set_highlighted(line_number)
233 def _selection_changed(self):
234 """Respond to selection changes"""
235 selected = bool(self.selected_text())
236 self.copy_diff_action.setEnabled(selected)
238 def resizeEvent(self, event):
239 super(DiffTextEdit, self).resizeEvent(event)
240 if self.numbers:
241 self.numbers.refresh_size()
243 def save_scrollbar(self):
244 """Save the scrollbar value, but only on the first call"""
245 if self.scrollvalue is None:
246 scrollbar = self.verticalScrollBar()
247 if scrollbar:
248 scrollvalue = get(scrollbar)
249 else:
250 scrollvalue = None
251 self.scrollvalue = scrollvalue
253 def restore_scrollbar(self):
254 """Restore the scrollbar and clear state"""
255 scrollbar = self.verticalScrollBar()
256 scrollvalue = self.scrollvalue
257 if scrollbar and scrollvalue is not None:
258 scrollbar.setValue(scrollvalue)
259 self.scrollvalue = None
261 def set_loading_message(self):
262 """Add a pending loading message in the diff view"""
263 self.hint.set_value('+++ ' + N_('Loading...'))
264 self.set_value('')
266 def set_diff(self, diff):
267 """Set the diff text, but save the scrollbar"""
268 diff = diff.rstrip('\n') # diffs include two empty newlines
269 self.save_scrollbar()
271 self.hint.set_value('')
272 if self.numbers:
273 self.numbers.set_diff(diff)
274 self.set_value(diff)
276 self.restore_scrollbar()
278 def selected_diff_stripped(self):
279 """Return the selected diff stripped of any diff characters"""
280 sep, selection = self.selected_text_lines()
281 return sep.join(_strip_diff(line) for line in selection)
283 def copy_diff(self):
284 """Copy the selected diff text stripped of any diff prefix characters"""
285 text = self.selected_diff_stripped()
286 qtutils.set_clipboard(text)
288 def selected_lines(self):
289 """Return selected lines"""
290 cursor = self.textCursor()
291 selection_start = cursor.selectionStart()
292 selection_end = max(selection_start, cursor.selectionEnd() - 1)
294 first_line_idx = -1
295 last_line_idx = -1
296 line_idx = 0
297 line_start = 0
299 for line_idx, line in enumerate(get(self, default='').splitlines()):
300 line_end = line_start + len(line)
301 if line_start <= selection_start <= line_end:
302 first_line_idx = line_idx
303 if line_start <= selection_end <= line_end:
304 last_line_idx = line_idx
305 break
306 line_start = line_end + 1
308 if first_line_idx == -1:
309 first_line_idx = line_idx
311 if last_line_idx == -1:
312 last_line_idx = line_idx
314 return first_line_idx, last_line_idx
316 def selected_text_lines(self):
317 """Return selected lines and the CRLF / LF separator"""
318 first_line_idx, last_line_idx = self.selected_lines()
319 text = get(self, default='')
320 sep = _get_sep(text)
321 lines = []
322 for line_idx, line in enumerate(text.split(sep)):
323 if first_line_idx <= line_idx <= last_line_idx:
324 lines.append(line)
325 return sep, lines
328 def _get_sep(text):
329 """Return either CRLF or LF based on the content"""
330 if '\r\n' in text:
331 sep = '\r\n'
332 else:
333 sep = '\n'
334 return sep
337 def _strip_diff(value):
338 """Remove +/-/<space> from a selection"""
339 if value.startswith(('+', '-', ' ')):
340 return value[1:]
341 return value
344 class DiffLineNumbers(TextDecorator):
345 def __init__(self, context, parent):
346 TextDecorator.__init__(self, parent)
347 self.highlight_line = -1
348 self.lines = None
349 self.parser = diffparse.DiffLines()
350 self.formatter = diffparse.FormatDigits()
352 self.setFont(qtutils.diff_font(context))
353 self._char_width = self.fontMetrics().width('0')
355 QPalette = QtGui.QPalette
356 self._palette = palette = self.palette()
357 self._base = palette.color(QtGui.QPalette.Base)
358 self._highlight = palette.color(QPalette.Highlight)
359 self._highlight.setAlphaF(0.3)
360 self._highlight_text = palette.color(QPalette.HighlightedText)
361 self._window = palette.color(QPalette.Window)
362 self._disabled = palette.color(QPalette.Disabled, QPalette.Text)
364 def set_diff(self, diff):
365 self.lines = self.parser.parse(diff)
366 self.formatter.set_digits(self.parser.digits())
368 def width_hint(self):
369 if not self.isVisible():
370 return 0
371 parser = self.parser
373 if parser.merge:
374 columns = 3
375 extra = 3 # one space in-between, one space after
376 else:
377 columns = 2
378 extra = 2 # one space in-between, one space after
380 digits = parser.digits() * columns
382 return defs.margin + (self._char_width * (digits + extra))
384 def set_highlighted(self, line_number):
385 """Set the line to highlight"""
386 self.highlight_line = line_number
388 def current_line(self):
389 lines = self.lines
390 if lines and self.highlight_line >= 0:
391 # Find the next valid line
392 for i in range(self.highlight_line, len(lines)):
393 # take the "new" line number: last value in tuple
394 line_number = lines[i][-1]
395 if line_number > 0:
396 return line_number
398 # Find the previous valid line
399 for i in range(self.highlight_line - 1, -1, -1):
400 # take the "new" line number: last value in tuple
401 if i < len(lines):
402 line_number = lines[i][-1]
403 if line_number > 0:
404 return line_number
405 return None
407 def paintEvent(self, event):
408 """Paint the line number"""
409 if not self.lines:
410 return
412 painter = QtGui.QPainter(self)
413 painter.fillRect(event.rect(), self._base)
415 editor = self.editor
416 content_offset = editor.contentOffset()
417 block = editor.firstVisibleBlock()
418 width = self.width()
419 text_width = width - (defs.margin * 2)
420 text_flags = Qt.AlignRight | Qt.AlignVCenter
421 event_rect_bottom = event.rect().bottom()
423 highlight_line = self.highlight_line
424 highlight = self._highlight
425 highlight_text = self._highlight_text
426 disabled = self._disabled
428 fmt = self.formatter
429 lines = self.lines
430 num_lines = len(lines)
432 while block.isValid():
433 block_number = block.blockNumber()
434 if block_number >= num_lines:
435 break
436 block_geom = editor.blockBoundingGeometry(block)
437 rect = block_geom.translated(content_offset).toRect()
438 if not block.isVisible() or rect.top() >= event_rect_bottom:
439 break
441 if block_number == highlight_line:
442 painter.fillRect(rect.x(), rect.y(), width, rect.height(), highlight)
443 painter.setPen(highlight_text)
444 else:
445 painter.setPen(disabled)
447 line = lines[block_number]
448 if len(line) == 2:
449 a, b = line
450 text = fmt.value(a, b)
451 elif len(line) == 3:
452 old, base, new = line
453 text = fmt.merge_value(old, base, new)
455 painter.drawText(
456 rect.x(),
457 rect.y(),
458 text_width,
459 rect.height(),
460 text_flags,
461 text,
464 block = block.next()
467 class Viewer(QtWidgets.QFrame):
468 """Text and image diff viewers"""
470 INDEX_TEXT = 0
471 INDEX_IMAGE = 1
473 def __init__(self, context, parent=None):
474 super(Viewer, self).__init__(parent)
476 self.context = context
477 self.model = model = context.model
478 self.images = []
479 self.pixmaps = []
480 self.options = options = Options(self)
481 self.text = DiffEditor(context, options, self)
482 self.image = imageview.ImageView(parent=self)
483 self.image.setFocusPolicy(Qt.NoFocus)
484 self.search_widget = TextSearchWidget(self)
485 self.search_widget.hide()
487 stack = self.stack = QtWidgets.QStackedWidget(self)
488 stack.addWidget(self.text)
489 stack.addWidget(self.image)
491 self.main_layout = qtutils.vbox(
492 defs.no_margin,
493 defs.no_spacing,
494 self.stack,
495 self.search_widget,
497 self.setLayout(self.main_layout)
499 # Observe images
500 model.images_changed.connect(self.set_images, type=Qt.QueuedConnection)
502 # Observe the diff type
503 model.diff_type_changed.connect(self.set_diff_type, type=Qt.QueuedConnection)
505 # Observe the file type
506 model.file_type_changed.connect(self.set_file_type, type=Qt.QueuedConnection)
508 # Observe the image mode combo box
509 options.image_mode.currentIndexChanged.connect(lambda _: self.render())
510 options.zoom_mode.currentIndexChanged.connect(lambda _: self.render())
512 self.setFocusProxy(self.text)
514 self.search_widget.search_text.connect(self.search_text)
516 self.search_action = qtutils.add_action(
517 self,
518 N_('Search in Diff'),
519 self.show_search_diff,
520 hotkeys.SEARCH,
522 self.search_next_action = qtutils.add_action(
523 self,
524 N_('Find next item'),
525 self.search_widget.search,
526 hotkeys.SEARCH_NEXT,
528 self.search_prev_action = qtutils.add_action(
529 self,
530 N_('Find previous item'),
531 self.search_widget.search_backwards,
532 hotkeys.SEARCH_PREV,
535 def show_search_diff(self):
536 """Show a dialog for searching diffs"""
537 # The diff search is only active in text mode.
538 if self.stack.currentIndex() != self.INDEX_TEXT:
539 return
540 if not self.search_widget.isVisible():
541 self.search_widget.show()
542 self.search_widget.setFocus(True)
544 def search_text(self, text, backwards):
545 """Search the diff text for the given text"""
546 cursor = self.text.textCursor()
547 if cursor.hasSelection():
548 selected_text = cursor.selectedText()
549 case_sensitive = self.search_widget.is_case_sensitive()
550 if text_matches(case_sensitive, selected_text, text):
551 if backwards:
552 position = cursor.selectionStart()
553 else:
554 position = cursor.selectionEnd()
555 else:
556 if backwards:
557 position = cursor.selectionEnd()
558 else:
559 position = cursor.selectionStart()
560 cursor.setPosition(position)
561 self.text.setTextCursor(cursor)
563 flags = self.search_widget.find_flags(backwards)
564 if not self.text.find(text, flags):
565 if backwards:
566 location = QtGui.QTextCursor.End
567 else:
568 location = QtGui.QTextCursor.Start
569 cursor.movePosition(location, QtGui.QTextCursor.MoveAnchor)
570 self.text.setTextCursor(cursor)
571 self.text.find(text, flags)
573 def export_state(self, state):
574 state['show_diff_line_numbers'] = self.options.show_line_numbers.isChecked()
575 state['image_diff_mode'] = self.options.image_mode.currentIndex()
576 state['image_zoom_mode'] = self.options.zoom_mode.currentIndex()
577 state['word_wrap'] = self.options.enable_word_wrapping.isChecked()
578 return state
580 def apply_state(self, state):
581 diff_numbers = bool(state.get('show_diff_line_numbers', False))
582 self.set_line_numbers(diff_numbers, update=True)
584 image_mode = utils.asint(state.get('image_diff_mode', 0))
585 self.options.image_mode.set_index(image_mode)
587 zoom_mode = utils.asint(state.get('image_zoom_mode', 0))
588 self.options.zoom_mode.set_index(zoom_mode)
590 word_wrap = bool(state.get('word_wrap', True))
591 self.set_word_wrapping(word_wrap, update=True)
592 return True
594 def set_diff_type(self, diff_type):
595 """Manage the image and text diff views when selection changes"""
596 # The "diff type" is whether the diff viewer is displaying an image.
597 self.options.set_diff_type(diff_type)
598 if diff_type == main.Types.IMAGE:
599 self.stack.setCurrentWidget(self.image)
600 self.search_widget.hide()
601 self.render()
602 else:
603 self.stack.setCurrentWidget(self.text)
605 def set_file_type(self, file_type):
606 """Manage the diff options when the file type changes"""
607 # The "file type" is whether the file itself is an image.
608 self.options.set_file_type(file_type)
610 def update_options(self):
611 """Emit a signal indicating that options have changed"""
612 self.text.update_options()
614 def set_line_numbers(self, enabled, update=False):
615 """Enable/disable line numbers in the text widget"""
616 self.text.set_line_numbers(enabled, update=update)
618 def set_word_wrapping(self, enabled, update=False):
619 """Enable/disable word wrapping in the text widget"""
620 self.text.set_word_wrapping(enabled, update=update)
622 def reset(self):
623 self.image.pixmap = QtGui.QPixmap()
624 self.cleanup()
626 def cleanup(self):
627 for (image, unlink) in self.images:
628 if unlink and core.exists(image):
629 os.unlink(image)
630 self.images = []
632 def set_images(self, images):
633 self.images = images
634 self.pixmaps = []
635 if not images:
636 self.reset()
637 return False
639 # In order to comp, we first have to load all the images
640 all_pixmaps = [QtGui.QPixmap(image[0]) for image in images]
641 pixmaps = [pixmap for pixmap in all_pixmaps if not pixmap.isNull()]
642 if not pixmaps:
643 self.reset()
644 return False
646 self.pixmaps = pixmaps
647 self.render()
648 self.cleanup()
649 return True
651 def render(self):
652 # Update images
653 if self.pixmaps:
654 mode = self.options.image_mode.currentIndex()
655 if mode == self.options.SIDE_BY_SIDE:
656 image = self.render_side_by_side()
657 elif mode == self.options.DIFF:
658 image = self.render_diff()
659 elif mode == self.options.XOR:
660 image = self.render_xor()
661 elif mode == self.options.PIXEL_XOR:
662 image = self.render_pixel_xor()
663 else:
664 image = self.render_side_by_side()
665 else:
666 image = QtGui.QPixmap()
667 self.image.pixmap = image
669 # Apply zoom
670 zoom_mode = self.options.zoom_mode.currentIndex()
671 zoom_factor = self.options.zoom_factors[zoom_mode][1]
672 if zoom_factor > 0.0:
673 self.image.resetTransform()
674 self.image.scale(zoom_factor, zoom_factor)
675 poly = self.image.mapToScene(self.image.viewport().rect())
676 self.image.last_scene_roi = poly.boundingRect()
678 def render_side_by_side(self):
679 # Side-by-side lineup comp
680 pixmaps = self.pixmaps
681 width = sum(pixmap.width() for pixmap in pixmaps)
682 height = max(pixmap.height() for pixmap in pixmaps)
683 image = create_image(width, height)
685 # Paint each pixmap
686 painter = create_painter(image)
687 x = 0
688 for pixmap in pixmaps:
689 painter.drawPixmap(x, 0, pixmap)
690 x += pixmap.width()
691 painter.end()
693 return image
695 def render_comp(self, comp_mode):
696 # Get the max size to use as the render canvas
697 pixmaps = self.pixmaps
698 if len(pixmaps) == 1:
699 return pixmaps[0]
701 width = max(pixmap.width() for pixmap in pixmaps)
702 height = max(pixmap.height() for pixmap in pixmaps)
703 image = create_image(width, height)
705 painter = create_painter(image)
706 for pixmap in (pixmaps[0], pixmaps[-1]):
707 x = (width - pixmap.width()) // 2
708 y = (height - pixmap.height()) // 2
709 painter.drawPixmap(x, y, pixmap)
710 painter.setCompositionMode(comp_mode)
711 painter.end()
713 return image
715 def render_diff(self):
716 comp_mode = QtGui.QPainter.CompositionMode_Difference
717 return self.render_comp(comp_mode)
719 def render_xor(self):
720 comp_mode = QtGui.QPainter.CompositionMode_Xor
721 return self.render_comp(comp_mode)
723 def render_pixel_xor(self):
724 comp_mode = QtGui.QPainter.RasterOp_SourceXorDestination
725 return self.render_comp(comp_mode)
728 def create_image(width, height):
729 size = QtCore.QSize(width, height)
730 image = QtGui.QImage(size, QtGui.QImage.Format_ARGB32_Premultiplied)
731 image.fill(Qt.transparent)
732 return image
735 def text_matches(case_sensitive, a, b):
736 """Compare text with case sensitivity taken into account"""
737 if case_sensitive:
738 return a == b
739 return a.lower() == b.lower()
742 def create_painter(image):
743 painter = QtGui.QPainter(image)
744 painter.fillRect(image.rect(), Qt.transparent)
745 return painter
748 class Options(QtWidgets.QWidget):
749 """Provide the options widget used by the editor
751 Actions are registered on the parent widget.
755 # mode combobox indexes
756 SIDE_BY_SIDE = 0
757 DIFF = 1
758 XOR = 2
759 PIXEL_XOR = 3
761 def __init__(self, parent):
762 super(Options, self).__init__(parent)
763 # Create widgets
764 self.widget = parent
765 self.ignore_space_at_eol = self.add_option(
766 N_('Ignore changes in whitespace at EOL')
769 self.ignore_space_change = self.add_option(
770 N_('Ignore changes in amount of whitespace')
773 self.ignore_all_space = self.add_option(N_('Ignore all whitespace'))
775 self.function_context = self.add_option(
776 N_('Show whole surrounding functions of changes')
779 self.show_line_numbers = qtutils.add_action_bool(
780 self, N_('Show line numbers'), self.set_line_numbers, True
782 self.enable_word_wrapping = qtutils.add_action_bool(
783 self, N_('Enable word wrapping'), self.set_word_wrapping, True
786 self.options = qtutils.create_action_button(
787 tooltip=N_('Diff Options'), icon=icons.configure()
790 self.toggle_image_diff = qtutils.create_action_button(
791 tooltip=N_('Toggle image diff'), icon=icons.visualize()
793 self.toggle_image_diff.hide()
795 self.image_mode = qtutils.combo(
796 [N_('Side by side'), N_('Diff'), N_('XOR'), N_('Pixel XOR')]
799 self.zoom_factors = (
800 (N_('Zoom to Fit'), 0.0),
801 (N_('25%'), 0.25),
802 (N_('50%'), 0.5),
803 (N_('100%'), 1.0),
804 (N_('200%'), 2.0),
805 (N_('400%'), 4.0),
806 (N_('800%'), 8.0),
808 zoom_modes = [factor[0] for factor in self.zoom_factors]
809 self.zoom_mode = qtutils.combo(zoom_modes, parent=self)
811 self.menu = menu = qtutils.create_menu(N_('Diff Options'), self.options)
812 self.options.setMenu(menu)
813 menu.addAction(self.ignore_space_at_eol)
814 menu.addAction(self.ignore_space_change)
815 menu.addAction(self.ignore_all_space)
816 menu.addSeparator()
817 menu.addAction(self.function_context)
818 menu.addAction(self.show_line_numbers)
819 menu.addSeparator()
820 menu.addAction(self.enable_word_wrapping)
822 # Layouts
823 layout = qtutils.hbox(
824 defs.no_margin,
825 defs.button_spacing,
826 self.image_mode,
827 self.zoom_mode,
828 self.options,
829 self.toggle_image_diff,
831 self.setLayout(layout)
833 # Policies
834 self.image_mode.setFocusPolicy(Qt.NoFocus)
835 self.zoom_mode.setFocusPolicy(Qt.NoFocus)
836 self.options.setFocusPolicy(Qt.NoFocus)
837 self.toggle_image_diff.setFocusPolicy(Qt.NoFocus)
838 self.setFocusPolicy(Qt.NoFocus)
840 def set_file_type(self, file_type):
841 """Set whether we are viewing an image file type"""
842 is_image = file_type == main.Types.IMAGE
843 self.toggle_image_diff.setVisible(is_image)
845 def set_diff_type(self, diff_type):
846 """Toggle between image and text diffs"""
847 is_text = diff_type == main.Types.TEXT
848 is_image = diff_type == main.Types.IMAGE
849 self.options.setVisible(is_text)
850 self.image_mode.setVisible(is_image)
851 self.zoom_mode.setVisible(is_image)
852 if is_image:
853 self.toggle_image_diff.setIcon(icons.diff())
854 else:
855 self.toggle_image_diff.setIcon(icons.visualize())
857 def add_option(self, title):
858 """Add a diff option which calls update_options() on change"""
859 action = qtutils.add_action(self, title, self.update_options)
860 action.setCheckable(True)
861 return action
863 def update_options(self):
864 """Update diff options in response to UI events"""
865 space_at_eol = get(self.ignore_space_at_eol)
866 space_change = get(self.ignore_space_change)
867 all_space = get(self.ignore_all_space)
868 function_context = get(self.function_context)
869 gitcmds.update_diff_overrides(
870 space_at_eol, space_change, all_space, function_context
872 self.widget.update_options()
874 def set_line_numbers(self, value):
875 """Enable / disable line numbers"""
876 self.widget.set_line_numbers(value, update=False)
878 def set_word_wrapping(self, value):
879 """Respond to Qt action callbacks"""
880 self.widget.set_word_wrapping(value, update=False)
882 def hide_advanced_options(self):
883 """Hide advanced options that are not applicable to the DiffWidget"""
884 self.show_line_numbers.setVisible(False)
885 self.ignore_space_at_eol.setVisible(False)
886 self.ignore_space_change.setVisible(False)
887 self.ignore_all_space.setVisible(False)
888 self.function_context.setVisible(False)
891 # pylint: disable=too-many-ancestors
892 class DiffEditor(DiffTextEdit):
894 up = Signal()
895 down = Signal()
896 options_changed = Signal()
898 def __init__(self, context, options, parent):
899 DiffTextEdit.__init__(self, context, parent, numbers=True)
900 self.context = context
901 self.model = model = context.model
902 self.selection_model = selection_model = context.selection
904 # "Diff Options" tool menu
905 self.options = options
907 self.action_apply_selection = qtutils.add_action(
908 self,
909 'Apply',
910 self.apply_selection,
911 hotkeys.STAGE_DIFF,
912 hotkeys.STAGE_DIFF_ALT,
915 self.action_revert_selection = qtutils.add_action(
916 self, 'Revert', self.revert_selection, hotkeys.REVERT
918 self.action_revert_selection.setIcon(icons.undo())
920 self.action_edit_and_apply_selection = qtutils.add_action(
921 self,
922 'Edit and Apply',
923 partial(self.apply_selection, edit=True),
924 hotkeys.EDIT_AND_STAGE_DIFF,
927 self.action_edit_and_revert_selection = qtutils.add_action(
928 self,
929 'Edit and Revert',
930 partial(self.revert_selection, edit=True),
931 hotkeys.EDIT_AND_REVERT,
933 self.action_edit_and_revert_selection.setIcon(icons.undo())
934 self.launch_editor = actions.launch_editor_at_line(
935 context, self, hotkeys.EDIT_SHORT, *hotkeys.ACCEPT
937 self.launch_difftool = actions.launch_difftool(context, self)
938 self.stage_or_unstage = actions.stage_or_unstage(context, self)
940 # Emit up/down signals so that they can be routed by the main widget
941 self.move_up = actions.move_up(self)
942 self.move_down = actions.move_down(self)
944 model.diff_text_updated.connect(self.set_diff, type=Qt.QueuedConnection)
946 selection_model.selection_changed.connect(
947 self.refresh, type=Qt.QueuedConnection
949 # Update the selection model when the cursor changes
950 self.cursorPositionChanged.connect(self._update_line_number)
952 qtutils.connect_button(options.toggle_image_diff, self.toggle_diff_type)
954 def toggle_diff_type(self):
955 cmds.do(cmds.ToggleDiffType, self.context)
957 def refresh(self):
958 enabled = False
959 s = self.selection_model.selection()
960 model = self.model
961 if model.is_partially_stageable():
962 item = s.modified[0] if s.modified else None
963 if item in model.submodules:
964 pass
965 elif item not in model.unstaged_deleted:
966 enabled = True
967 self.action_revert_selection.setEnabled(enabled)
969 def set_line_numbers(self, enabled, update=False):
970 """Enable/disable the diff line number display"""
971 self.numbers.setVisible(enabled)
972 if update:
973 with qtutils.BlockSignals(self.options.show_line_numbers):
974 self.options.show_line_numbers.setChecked(enabled)
975 # Refresh the display. Not doing this results in the display not
976 # correctly displaying the line numbers widget until the text scrolls.
977 self.set_value(self.value())
979 def update_options(self):
980 self.options_changed.emit()
982 def create_context_menu(self, event_pos):
983 """Override create_context_menu() to display a completely custom menu"""
984 menu = super(DiffEditor, self).create_context_menu(event_pos)
985 context = self.context
986 model = self.model
987 s = self.selection_model.selection()
988 filename = self.selection_model.filename()
990 # These menu actions will be inserted at the start of the widget.
991 current_actions = menu.actions()
992 menu_actions = []
993 add_action = menu_actions.append
995 if model.is_stageable() or model.is_unstageable():
996 if model.is_stageable():
997 self.stage_or_unstage.setText(N_('Stage'))
998 self.stage_or_unstage.setIcon(icons.add())
999 else:
1000 self.stage_or_unstage.setText(N_('Unstage'))
1001 self.stage_or_unstage.setIcon(icons.remove())
1002 add_action(self.stage_or_unstage)
1004 if model.is_partially_stageable():
1005 item = s.modified[0] if s.modified else None
1006 if item in model.submodules:
1007 path = core.abspath(item)
1008 action = qtutils.add_action_with_icon(
1009 menu,
1010 icons.add(),
1011 cmds.Stage.name(),
1012 cmds.run(cmds.Stage, context, s.modified),
1013 hotkeys.STAGE_SELECTION,
1015 add_action(action)
1017 action = qtutils.add_action_with_icon(
1018 menu,
1019 icons.cola(),
1020 N_('Launch git-cola'),
1021 cmds.run(cmds.OpenRepo, context, path),
1023 add_action(action)
1024 elif item not in model.unstaged_deleted:
1025 if self.has_selection():
1026 apply_text = N_('Stage Selected Lines')
1027 edit_and_apply_text = N_('Edit Selected Lines to Stage...')
1028 revert_text = N_('Revert Selected Lines...')
1029 edit_and_revert_text = N_('Edit Selected Lines to Revert...')
1030 else:
1031 apply_text = N_('Stage Diff Hunk')
1032 edit_and_apply_text = N_('Edit Diff Hunk to Stage...')
1033 revert_text = N_('Revert Diff Hunk...')
1034 edit_and_revert_text = N_('Edit Diff Hunk to Revert...')
1036 self.action_apply_selection.setText(apply_text)
1037 self.action_apply_selection.setIcon(icons.add())
1038 add_action(self.action_apply_selection)
1040 self.action_edit_and_apply_selection.setText(edit_and_apply_text)
1041 self.action_edit_and_apply_selection.setIcon(icons.add())
1042 add_action(self.action_edit_and_apply_selection)
1044 self.action_revert_selection.setText(revert_text)
1045 add_action(self.action_revert_selection)
1047 self.action_edit_and_revert_selection.setText(edit_and_revert_text)
1048 add_action(self.action_edit_and_revert_selection)
1050 if s.staged and model.is_unstageable():
1051 item = s.staged[0]
1052 if item in model.submodules:
1053 path = core.abspath(item)
1054 action = qtutils.add_action_with_icon(
1055 menu,
1056 icons.remove(),
1057 cmds.Unstage.name(),
1058 cmds.run(cmds.Unstage, context, s.staged),
1059 hotkeys.STAGE_SELECTION,
1061 add_action(action)
1063 qtutils.add_action_with_icon(
1064 menu,
1065 icons.cola(),
1066 N_('Launch git-cola'),
1067 cmds.run(cmds.OpenRepo, context, path),
1069 add_action(action)
1071 elif item not in model.staged_deleted:
1072 if self.has_selection():
1073 apply_text = N_('Unstage Selected Lines')
1074 edit_and_apply_text = N_('Edit Selected Lines to Unstage...')
1075 else:
1076 apply_text = N_('Unstage Diff Hunk')
1077 edit_and_apply_text = N_('Edit Diff Hunk to Unstage...')
1079 self.action_apply_selection.setText(apply_text)
1080 self.action_apply_selection.setIcon(icons.remove())
1081 add_action(self.action_apply_selection)
1083 self.action_edit_and_apply_selection.setText(edit_and_apply_text)
1084 self.action_edit_and_apply_selection.setIcon(icons.remove())
1085 add_action(self.action_edit_and_apply_selection)
1087 if model.is_stageable() or model.is_unstageable():
1088 # Do not show the "edit" action when the file does not exist.
1089 # Untracked files exist by definition.
1090 if filename and core.exists(filename):
1091 add_action(qtutils.menu_separator(menu))
1092 add_action(self.launch_editor)
1094 # Removed files can still be diffed.
1095 add_action(self.launch_difftool)
1097 # Add the Previous/Next File actions, which improves discoverability
1098 # of their associated shortcuts
1099 add_action(qtutils.menu_separator(menu))
1100 add_action(self.move_up)
1101 add_action(self.move_down)
1102 add_action(qtutils.menu_separator(menu))
1104 if current_actions:
1105 first_action = current_actions[0]
1106 else:
1107 first_action = None
1108 menu.insertActions(first_action, menu_actions)
1110 return menu
1112 def mousePressEvent(self, event):
1113 if event.button() == Qt.RightButton:
1114 # Intercept right-click to move the cursor to the current position.
1115 # setTextCursor() clears the selection so this is only done when
1116 # nothing is selected.
1117 if not self.has_selection():
1118 cursor = self.cursorForPosition(event.pos())
1119 self.setTextCursor(cursor)
1121 return super(DiffEditor, self).mousePressEvent(event)
1123 def setPlainText(self, text):
1124 """setPlainText(str) while retaining scrollbar positions"""
1125 model = self.model
1126 mode = model.mode
1127 highlight = mode not in (
1128 model.mode_none,
1129 model.mode_display,
1130 model.mode_untracked,
1132 self.highlighter.set_enabled(highlight)
1134 scrollbar = self.verticalScrollBar()
1135 if scrollbar:
1136 scrollvalue = get(scrollbar)
1137 else:
1138 scrollvalue = None
1140 if text is None:
1141 return
1143 DiffTextEdit.setPlainText(self, text)
1145 if scrollbar and scrollvalue is not None:
1146 scrollbar.setValue(scrollvalue)
1148 def apply_selection(self, *, edit=False):
1149 model = self.model
1150 s = self.selection_model.single_selection()
1151 if model.is_partially_stageable() and (s.modified or s.untracked):
1152 self.process_diff_selection(edit=edit)
1153 elif model.is_unstageable():
1154 self.process_diff_selection(reverse=True, edit=edit)
1156 def revert_selection(self, *, edit=False):
1157 """Destructively revert selected lines or hunk from a worktree file."""
1159 if not edit:
1160 if self.has_selection():
1161 title = N_('Revert Selected Lines?')
1162 ok_text = N_('Revert Selected Lines')
1163 else:
1164 title = N_('Revert Diff Hunk?')
1165 ok_text = N_('Revert Diff Hunk')
1167 if not Interaction.confirm(
1168 title,
1170 'This operation drops uncommitted changes.\n'
1171 'These changes cannot be recovered.'
1173 N_('Revert the uncommitted changes?'),
1174 ok_text,
1175 default=True,
1176 icon=icons.undo(),
1178 return
1179 self.process_diff_selection(reverse=True, apply_to_worktree=True, edit=edit)
1181 def extract_patch(self, reverse=False):
1182 first_line_idx, last_line_idx = self.selected_lines()
1183 patch = diffparse.Patch.parse(self.model.filename, self.model.diff_text)
1184 if self.has_selection():
1185 return patch.extract_subset(first_line_idx, last_line_idx, reverse=reverse)
1186 return patch.extract_hunk(first_line_idx, reverse=reverse)
1188 def patch_encoding(self):
1189 if isinstance(self.model.diff_text, core.UStr):
1190 # original encoding must prevail
1191 return self.model.diff_text.encoding
1192 return self.context.cfg.file_encoding(self.model.filename)
1194 def process_diff_selection(
1195 self, reverse=False, apply_to_worktree=False, edit=False
1197 """Implement un/staging of the selected line(s) or hunk."""
1198 if self.selection_model.is_empty():
1199 return
1200 patch = self.extract_patch(reverse)
1201 if not patch.has_changes():
1202 return
1203 patch_encoding = self.patch_encoding()
1205 if edit:
1206 patch = edit_patch(
1207 patch,
1208 patch_encoding,
1209 self.context,
1210 reverse=reverse,
1211 apply_to_worktree=apply_to_worktree,
1213 if not patch.has_changes():
1214 return
1216 cmds.do(
1217 cmds.ApplyPatch,
1218 self.context,
1219 patch,
1220 patch_encoding,
1221 apply_to_worktree,
1224 def _update_line_number(self):
1225 """Update the selection model when the cursor changes"""
1226 self.selection_model.line_number = self.numbers.current_line()
1229 class DiffWidget(QtWidgets.QWidget):
1230 """Display commit metadata and text diffs"""
1232 def __init__(self, context, parent, is_commit=False, options=None):
1233 QtWidgets.QWidget.__init__(self, parent)
1235 self.context = context
1236 self.oid = 'HEAD'
1237 self.oid_start = None
1238 self.oid_end = None
1239 self.options = options
1241 author_font = QtGui.QFont(self.font())
1242 author_font.setPointSize(int(author_font.pointSize() * 1.1))
1244 summary_font = QtGui.QFont(author_font)
1245 summary_font.setWeight(QtGui.QFont.Bold)
1247 policy = QtWidgets.QSizePolicy(
1248 QtWidgets.QSizePolicy.MinimumExpanding, QtWidgets.QSizePolicy.Minimum
1251 self.gravatar_label = gravatar.GravatarLabel(self.context, parent=self)
1253 self.author_label = TextLabel()
1254 self.author_label.setTextFormat(Qt.RichText)
1255 self.author_label.setFont(author_font)
1256 self.author_label.setSizePolicy(policy)
1257 self.author_label.setAlignment(Qt.AlignBottom)
1258 self.author_label.elide()
1260 self.date_label = TextLabel()
1261 self.date_label.setTextFormat(Qt.PlainText)
1262 self.date_label.setSizePolicy(policy)
1263 self.date_label.setAlignment(Qt.AlignTop)
1264 self.date_label.hide()
1266 self.summary_label = TextLabel()
1267 self.summary_label.setTextFormat(Qt.PlainText)
1268 self.summary_label.setFont(summary_font)
1269 self.summary_label.setSizePolicy(policy)
1270 self.summary_label.setAlignment(Qt.AlignTop)
1271 self.summary_label.elide()
1273 self.oid_label = TextLabel()
1274 self.oid_label.setTextFormat(Qt.PlainText)
1275 self.oid_label.setSizePolicy(policy)
1276 self.oid_label.setAlignment(Qt.AlignTop)
1277 self.oid_label.elide()
1279 self.diff = DiffTextEdit(context, self, is_commit=is_commit, whitespace=False)
1280 self.setFocusProxy(self.diff)
1282 self.info_layout = qtutils.vbox(
1283 defs.no_margin,
1284 defs.no_spacing,
1285 self.author_label,
1286 self.date_label,
1287 self.summary_label,
1288 self.oid_label,
1291 self.logo_layout = qtutils.hbox(
1292 defs.no_margin, defs.button_spacing, self.gravatar_label, self.info_layout
1294 self.logo_layout.setContentsMargins(defs.margin, 0, defs.margin, 0)
1296 self.main_layout = qtutils.vbox(
1297 defs.no_margin, defs.spacing, self.logo_layout, self.diff
1299 self.setLayout(self.main_layout)
1301 self.set_tabwidth(prefs.tabwidth(context))
1303 def set_tabwidth(self, width):
1304 self.diff.set_tabwidth(width)
1306 def set_word_wrapping(self, enabled, update=False):
1307 """Enable and disable word wrapping"""
1308 self.diff.set_word_wrapping(enabled, update=update)
1310 def set_options(self, options):
1311 """Register an options widget"""
1312 self.options = options
1313 self.diff.set_options(options)
1315 def start_diff_task(self, task):
1316 """Clear the display and start a diff-gathering task"""
1317 self.diff.save_scrollbar()
1318 self.diff.set_loading_message()
1319 self.context.runtask.start(task, result=self.set_diff)
1321 def set_diff_oid(self, oid, filename=None):
1322 """Set the diff from a single commit object ID"""
1323 task = DiffInfoTask(self.context, oid, filename)
1324 self.start_diff_task(task)
1326 def set_diff_range(self, start, end, filename=None):
1327 task = DiffRangeTask(self.context, start + '~', end, filename)
1328 self.start_diff_task(task)
1330 def commits_selected(self, commits):
1331 """Display an appropriate diff when commits are selected"""
1332 if not commits:
1333 self.clear()
1334 return
1335 commit = commits[-1]
1336 oid = commit.oid
1337 email = commit.email or ''
1338 summary = commit.summary or ''
1339 author = commit.author or ''
1340 self.set_details(oid, author, email, '', summary)
1341 self.oid = oid
1343 if len(commits) > 1:
1344 start, end = commits[0], commits[-1]
1345 self.set_diff_range(start.oid, end.oid)
1346 self.oid_start = start
1347 self.oid_end = end
1348 else:
1349 self.set_diff_oid(oid)
1350 self.oid_start = None
1351 self.oid_end = None
1353 def set_diff(self, diff):
1354 """Set the diff text"""
1355 self.diff.set_diff(diff)
1357 def set_details(self, oid, author, email, date, summary):
1358 template_args = {'author': author, 'email': email, 'summary': summary}
1359 author_text = (
1360 """%(author)s &lt;"""
1361 """<a href="mailto:%(email)s">"""
1362 """%(email)s</a>&gt;""" % template_args
1364 author_template = '%(author)s <%(email)s>' % template_args
1366 self.date_label.set_text(date)
1367 self.date_label.setVisible(bool(date))
1368 self.oid_label.set_text(oid)
1369 self.author_label.set_template(author_text, author_template)
1370 self.summary_label.set_text(summary)
1371 self.gravatar_label.set_email(email)
1373 def clear(self):
1374 self.date_label.set_text('')
1375 self.oid_label.set_text('')
1376 self.author_label.set_text('')
1377 self.summary_label.set_text('')
1378 self.gravatar_label.clear()
1379 self.diff.clear()
1381 def files_selected(self, filenames):
1382 """Update the view when a filename is selected"""
1383 if not filenames:
1384 return
1385 oid_start = self.oid_start
1386 oid_end = self.oid_end
1387 if oid_start and oid_end:
1388 self.set_diff_range(oid_start.oid, oid_end.oid, filename=filenames[0])
1389 else:
1390 self.set_diff_oid(self.oid, filename=filenames[0])
1393 class TextLabel(QtWidgets.QLabel):
1394 def __init__(self, parent=None):
1395 QtWidgets.QLabel.__init__(self, parent)
1396 self.setTextInteractionFlags(
1397 Qt.TextSelectableByMouse | Qt.LinksAccessibleByMouse
1399 self._display = ''
1400 self._template = ''
1401 self._text = ''
1402 self._elide = False
1403 self._metrics = QtGui.QFontMetrics(self.font())
1404 self.setOpenExternalLinks(True)
1406 def elide(self):
1407 self._elide = True
1409 def set_text(self, text):
1410 self.set_template(text, text)
1412 def set_template(self, text, template):
1413 self._display = text
1414 self._text = text
1415 self._template = template
1416 self.update_text(self.width())
1417 self.setText(self._display)
1419 def update_text(self, width):
1420 self._display = self._text
1421 if not self._elide:
1422 return
1423 text = self._metrics.elidedText(self._template, Qt.ElideRight, width - 2)
1424 if text != self._template:
1425 self._display = text
1427 # Qt overrides
1428 def setFont(self, font):
1429 self._metrics = QtGui.QFontMetrics(font)
1430 QtWidgets.QLabel.setFont(self, font)
1432 def resizeEvent(self, event):
1433 if self._elide:
1434 self.update_text(event.size().width())
1435 with qtutils.BlockSignals(self):
1436 self.setText(self._display)
1437 QtWidgets.QLabel.resizeEvent(self, event)
1440 class DiffInfoTask(qtutils.Task):
1441 """Gather diffs for a single commit"""
1443 def __init__(self, context, oid, filename):
1444 qtutils.Task.__init__(self)
1445 self.context = context
1446 self.oid = oid
1447 self.filename = filename
1449 def task(self):
1450 context = self.context
1451 oid = self.oid
1452 return gitcmds.diff_info(context, oid, filename=self.filename)
1455 class DiffRangeTask(qtutils.Task):
1456 """Gather diffs for a range of commits"""
1458 def __init__(self, context, start, end, filename):
1459 qtutils.Task.__init__(self)
1460 self.context = context
1461 self.start = start
1462 self.end = end
1463 self.filename = filename
1465 def task(self):
1466 context = self.context
1467 return gitcmds.diff_range(context, self.start, self.end, filename=self.filename)