text: defer calls to setStyleSheet()
[git-cola.git] / cola / widgets / diff.py
blobb216788e725574399080641fb11cbe362d30fc25
1 from __future__ import division, absolute_import, unicode_literals
2 import os
3 import re
5 from qtpy import QtCore
6 from qtpy import QtGui
7 from qtpy import QtWidgets
8 from qtpy.QtCore import Qt
9 from qtpy.QtCore import Signal
11 from ..i18n import N_
12 from ..interaction import Interaction
13 from ..models import prefs
14 from ..qtutils import get
15 from .. import actions
16 from .. import cmds
17 from .. import core
18 from .. import diffparse
19 from .. import gitcmds
20 from .. import gravatar
21 from .. import hotkeys
22 from .. import icons
23 from .. import utils
24 from .. import qtutils
25 from .text import TextDecorator
26 from .text import VimHintedPlainTextEdit
27 from . import defs
28 from . import imageview
31 COMMITS_SELECTED = 'COMMITS_SELECTED'
32 FILES_SELECTED = 'FILES_SELECTED'
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(r'(?:@@ -[0-9,]+ \+[0-9,]+ @@)|'
48 r'(?:@@@ (?:-[0-9,]+ ){2}\+[0-9,]+ @@@)')
49 BAD_WHITESPACE_RGX = re.compile(r'\s+$')
51 def __init__(self, context, doc, whitespace=True, is_commit=False):
52 QtGui.QSyntaxHighlighter.__init__(self, doc)
53 self.whitespace = whitespace
54 self.enabled = True
55 self.is_commit = is_commit
57 QPalette = QtGui.QPalette
58 cfg = context.cfg
59 palette = QPalette()
60 disabled = palette.color(QPalette.Disabled, QPalette.Text)
61 header = qtutils.rgb_hex(disabled)
63 self.color_text = qtutils.RGB(cfg.color('text', '030303'))
64 self.color_add = qtutils.RGB(cfg.color('add', 'd2ffe4'))
65 self.color_remove = qtutils.RGB(cfg.color('remove', 'fee0e4'))
66 self.color_header = qtutils.RGB(cfg.color('header', header))
68 self.diff_header_fmt = qtutils.make_format(fg=self.color_header)
69 self.bold_diff_header_fmt = qtutils.make_format(
70 fg=self.color_header, bold=True)
72 self.diff_add_fmt = qtutils.make_format(
73 fg=self.color_text, bg=self.color_add)
74 self.diff_remove_fmt = qtutils.make_format(
75 fg=self.color_text, bg=self.color_remove)
76 self.bad_whitespace_fmt = qtutils.make_format(bg=Qt.red)
77 self.setCurrentBlockState(self.INITIAL_STATE)
79 def set_enabled(self, enabled):
80 self.enabled = enabled
82 def highlightBlock(self, text):
83 if not self.enabled or not text:
84 return
85 # Aliases for quick local access
86 initial_state = self.INITIAL_STATE
87 default_state = self.DEFAULT_STATE
88 diff_state = self.DIFF_STATE
89 diffstat_state = self.DIFFSTAT_STATE
90 diff_file_header_state = self.DIFF_FILE_HEADER_STATE
91 submodule_state = self.SUBMODULE_STATE
92 end_state = self.END_STATE
94 diff_file_header_start_rgx = self.DIFF_FILE_HEADER_START_RGX
95 diff_hunk_header_rgx = self.DIFF_HUNK_HEADER_RGX
96 bad_whitespace_rgx = self.BAD_WHITESPACE_RGX
98 diff_header_fmt = self.diff_header_fmt
99 bold_diff_header_fmt = self.bold_diff_header_fmt
100 diff_add_fmt = self.diff_add_fmt
101 diff_remove_fmt = self.diff_remove_fmt
102 bad_whitespace_fmt = self.bad_whitespace_fmt
104 state = self.previousBlockState()
105 if state == initial_state:
106 if text.startswith('Submodule '):
107 state = submodule_state
108 elif text.startswith('diff --git '):
109 state = diffstat_state
110 elif self.is_commit:
111 state = default_state
112 else:
113 state = diffstat_state
115 if state == diffstat_state:
116 if diff_file_header_start_rgx.match(text):
117 state = diff_file_header_state
118 self.setFormat(0, len(text), diff_header_fmt)
119 elif diff_hunk_header_rgx.match(text):
120 state = diff_state
121 self.setFormat(0, len(text), bold_diff_header_fmt)
122 elif '|' in text:
123 i = text.index('|')
124 self.setFormat(0, i, bold_diff_header_fmt)
125 self.setFormat(i, len(text) - i, diff_header_fmt)
126 else:
127 self.setFormat(0, len(text), diff_header_fmt)
128 elif state == diff_file_header_state:
129 if diff_hunk_header_rgx.match(text):
130 state = diff_state
131 self.setFormat(0, len(text), bold_diff_header_fmt)
132 else:
133 self.setFormat(0, len(text), diff_header_fmt)
134 elif state == diff_state:
135 if diff_file_header_start_rgx.match(text):
136 state = diff_file_header_state
137 self.setFormat(0, len(text), diff_header_fmt)
138 elif diff_hunk_header_rgx.match(text):
139 self.setFormat(0, len(text), bold_diff_header_fmt)
140 elif text.startswith('-'):
141 if text == '-- ':
142 state = end_state
143 else:
144 self.setFormat(0, len(text), diff_remove_fmt)
145 elif text.startswith('+'):
146 self.setFormat(0, len(text), diff_add_fmt)
147 if self.whitespace:
148 m = bad_whitespace_rgx.search(text)
149 if m is not None:
150 i = m.start()
151 self.setFormat(i, len(text) - i, bad_whitespace_fmt)
153 self.setCurrentBlockState(state)
156 class DiffTextEdit(VimHintedPlainTextEdit):
158 def __init__(self, context, parent,
159 is_commit=False, whitespace=True, numbers=False):
160 VimHintedPlainTextEdit.__init__(self, context, '', parent=parent)
161 # Diff/patch syntax highlighter
162 self.highlighter = DiffSyntaxHighlighter(
163 context, self.document(),
164 is_commit=is_commit, whitespace=whitespace)
165 if numbers:
166 self.numbers = DiffLineNumbers(context, self)
167 self.numbers.hide()
168 else:
169 self.numbers = None
170 self.scrollvalue = None
172 self.cursorPositionChanged.connect(self._cursor_changed)
174 def _cursor_changed(self):
175 """Update the line number display when the cursor changes"""
176 line_number = max(0, self.textCursor().blockNumber())
177 if self.numbers:
178 self.numbers.set_highlighted(line_number)
180 def resizeEvent(self, event):
181 super(DiffTextEdit, self).resizeEvent(event)
182 if self.numbers:
183 self.numbers.refresh_size()
185 def save_scrollbar(self):
186 """Save the scrollbar value, but only on the first call"""
187 if self.scrollvalue is None:
188 scrollbar = self.verticalScrollBar()
189 if scrollbar:
190 scrollvalue = get(scrollbar)
191 else:
192 scrollvalue = None
193 self.scrollvalue = scrollvalue
195 def restore_scrollbar(self):
196 """Restore the scrollbar and clear state"""
197 scrollbar = self.verticalScrollBar()
198 scrollvalue = self.scrollvalue
199 if scrollbar and scrollvalue is not None:
200 scrollbar.setValue(scrollvalue)
201 self.scrollvalue = None
203 def set_loading_message(self):
204 self.hint.set_value('+++ ' + N_('Loading...'))
205 self.set_value('')
207 def set_diff(self, diff):
208 """Set the diff text, but save the scrollbar"""
209 diff = diff.rstrip('\n') # diffs include two empty newlines
210 self.save_scrollbar()
212 self.hint.set_value('')
213 if self.numbers:
214 self.numbers.set_diff(diff)
215 self.set_value(diff)
217 self.restore_scrollbar()
220 class DiffLineNumbers(TextDecorator):
222 def __init__(self, context, parent):
223 TextDecorator.__init__(self, parent)
224 self.highlight_line = -1
225 self.lines = None
226 self.parser = diffparse.DiffLines()
227 self.formatter = diffparse.FormatDigits()
229 self.setFont(qtutils.diff_font(context))
230 self._char_width = self.fontMetrics().width('0')
232 QPalette = QtGui.QPalette
233 self._palette = palette = self.palette()
234 self._base = palette.color(QtGui.QPalette.Base)
235 self._highlight = palette.color(QPalette.Highlight)
236 self._highlight_text = palette.color(QPalette.HighlightedText)
237 self._window = palette.color(QPalette.Window)
238 self._disabled = palette.color(QPalette.Disabled, QPalette.Text)
240 def set_diff(self, diff):
241 parser = self.parser
242 lines = parser.parse(diff)
243 if parser.valid:
244 self.lines = lines
245 self.formatter.set_digits(self.parser.digits())
246 else:
247 self.lines = None
249 def set_lines(self, lines):
250 self.lines = lines
252 def width_hint(self):
253 if not self.isVisible():
254 return 0
255 parser = self.parser
257 if parser.merge:
258 columns = 3
259 extra = 3 # one space in-between, one space after
260 else:
261 columns = 2
262 extra = 2 # one space in-between, one space after
264 if parser.valid:
265 digits = parser.digits() * columns
266 else:
267 digits = 4
269 return defs.margin + (self._char_width * (digits + extra))
271 def set_highlighted(self, line_number):
272 """Set the line to highlight"""
273 self.highlight_line = line_number
275 def current_line(self):
276 if self.lines and self.highlight_line >= 0:
277 # Find the next valid line
278 for line in self.lines[self.highlight_line:]:
279 # take the "new" line number: last value in tuple
280 line_number = line[-1]
281 if line_number > 0:
282 return line_number
284 # Find the previous valid line
285 for line in self.lines[self.highlight_line-1::-1]:
286 # take the "new" line number: last value in tuple
287 line_number = line[-1]
288 if line_number > 0:
289 return line_number
290 return None
292 def paintEvent(self, event):
293 """Paint the line number"""
294 if not self.lines:
295 return
297 painter = QtGui.QPainter(self)
298 painter.fillRect(event.rect(), self._base)
300 editor = self.editor
301 content_offset = editor.contentOffset()
302 block = editor.firstVisibleBlock()
303 width = self.width()
304 event_rect_bottom = event.rect().bottom()
306 highlight = self._highlight
307 highlight_text = self._highlight_text
308 disabled = self._disabled
310 fmt = self.formatter
311 lines = self.lines
312 num_lines = len(self.lines)
313 painter.setPen(disabled)
314 text = ''
316 while block.isValid():
317 block_number = block.blockNumber()
318 if block_number >= num_lines:
319 break
320 block_geom = editor.blockBoundingGeometry(block)
321 block_top = block_geom.translated(content_offset).top()
322 if not block.isVisible() or block_top >= event_rect_bottom:
323 break
325 rect = block_geom.translated(content_offset).toRect()
326 if block_number == self.highlight_line:
327 painter.fillRect(rect.x(), rect.y(),
328 width, rect.height(), highlight)
329 painter.setPen(highlight_text)
330 else:
331 painter.setPen(disabled)
333 line = lines[block_number]
334 if len(line) == 2:
335 a, b = line
336 text = fmt.value(a, b)
337 elif len(line) == 3:
338 old, base, new = line
339 text = fmt.merge_value(old, base, new)
341 painter.drawText(rect.x(), rect.y(),
342 self.width() - (defs.margin * 2), rect.height(),
343 Qt.AlignRight | Qt.AlignVCenter, text)
345 block = block.next() # pylint: disable=next-method-called
348 class Viewer(QtWidgets.QWidget):
349 """Text and image diff viewers"""
351 images_changed = Signal(object)
352 type_changed = Signal(object)
354 def __init__(self, context, parent=None):
355 super(Viewer, self).__init__(parent)
357 self.context = context
358 self.model = model = context.model
359 self.images = []
360 self.pixmaps = []
361 self.options = options = Options(self)
362 self.text = DiffEditor(context, options, self)
363 self.image = imageview.ImageView(parent=self)
364 self.image.setFocusPolicy(Qt.NoFocus)
366 stack = self.stack = QtWidgets.QStackedWidget(self)
367 stack.addWidget(self.text)
368 stack.addWidget(self.image)
370 self.main_layout = qtutils.vbox(
371 defs.no_margin, defs.no_spacing, self.stack)
372 self.setLayout(self.main_layout)
374 # Observe images
375 images_msg = model.message_images_changed
376 model.add_observer(images_msg, self.images_changed.emit)
377 self.images_changed.connect(self.set_images, type=Qt.QueuedConnection)
379 # Observe the diff type
380 diff_type_msg = model.message_diff_type_changed
381 model.add_observer(diff_type_msg, self.type_changed.emit)
382 self.type_changed.connect(self.set_diff_type, type=Qt.QueuedConnection)
384 # Observe the image mode combo box
385 options.image_mode.currentIndexChanged.connect(lambda _: self.render())
386 options.zoom_mode.currentIndexChanged.connect(lambda _: self.render())
388 self.setFocusProxy(self.text)
390 def export_state(self, state):
391 state['show_diff_line_numbers'] = self.text.show_line_numbers()
392 state['image_diff_mode'] = self.options.image_mode.currentIndex()
393 state['image_zoom_mode'] = self.options.zoom_mode.currentIndex()
394 return state
396 def apply_state(self, state):
397 diff_numbers = bool(state.get('show_diff_line_numbers', False))
398 self.text.enable_line_numbers(diff_numbers)
400 image_mode = utils.asint(state.get('image_diff_mode', 0))
401 self.options.image_mode.set_index(image_mode)
403 zoom_mode = utils.asint(state.get('image_zoom_mode', 0))
404 self.options.zoom_mode.set_index(zoom_mode)
405 return True
407 def set_diff_type(self, diff_type):
408 """Manage the image and text diff views when selection changes"""
409 self.options.set_diff_type(diff_type)
410 if diff_type == 'image':
411 self.stack.setCurrentWidget(self.image)
412 self.render()
413 else:
414 self.stack.setCurrentWidget(self.text)
416 def update_options(self):
417 self.text.update_options()
419 def reset(self):
420 self.image.pixmap = QtGui.QPixmap()
421 self.cleanup()
423 def cleanup(self):
424 for (image, unlink) in self.images:
425 if unlink and core.exists(image):
426 os.unlink(image)
427 self.images = []
429 def set_images(self, images):
430 self.images = images
431 self.pixmaps = []
432 if not images:
433 self.reset()
434 return False
436 # In order to comp, we first have to load all the images
437 all_pixmaps = [QtGui.QPixmap(image[0]) for image in images]
438 pixmaps = [pixmap for pixmap in all_pixmaps if not pixmap.isNull()]
439 if not pixmaps:
440 self.reset()
441 return False
443 self.pixmaps = pixmaps
444 self.render()
445 self.cleanup()
446 return True
448 def render(self):
449 # Update images
450 if self.pixmaps:
451 mode = self.options.image_mode.currentIndex()
452 if mode == self.options.SIDE_BY_SIDE:
453 image = self.render_side_by_side()
454 elif mode == self.options.DIFF:
455 image = self.render_diff()
456 elif mode == self.options.XOR:
457 image = self.render_xor()
458 elif mode == self.options.PIXEL_XOR:
459 image = self.render_pixel_xor()
460 else:
461 image = self.render_side_by_side()
462 else:
463 image = QtGui.QPixmap()
464 self.image.pixmap = image
466 # Apply zoom
467 zoom_mode = self.options.zoom_mode.currentIndex()
468 zoom_factor = self.options.zoom_factors[zoom_mode][1]
469 if zoom_factor > 0.0:
470 self.image.resetTransform()
471 self.image.scale(zoom_factor, zoom_factor)
472 poly = self.image.mapToScene(self.image.viewport().rect())
473 self.image.last_scene_roi = poly.boundingRect()
475 def render_side_by_side(self):
476 # Side-by-side lineup comp
477 pixmaps = self.pixmaps
478 width = sum([pixmap.width() for pixmap in pixmaps])
479 height = max([pixmap.height() for pixmap in pixmaps])
480 image = create_image(width, height)
482 # Paint each pixmap
483 painter = create_painter(image)
484 x = 0
485 for pixmap in pixmaps:
486 painter.drawPixmap(x, 0, pixmap)
487 x += pixmap.width()
488 painter.end()
490 return image
492 def render_comp(self, comp_mode):
493 # Get the max size to use as the render canvas
494 pixmaps = self.pixmaps
495 if len(pixmaps) == 1:
496 return pixmaps[0]
498 width = max([pixmap.width() for pixmap in pixmaps])
499 height = max([pixmap.height() for pixmap in pixmaps])
500 image = create_image(width, height)
502 painter = create_painter(image)
503 for pixmap in (pixmaps[0], pixmaps[-1]):
504 x = (width - pixmap.width()) // 2
505 y = (height - pixmap.height()) // 2
506 painter.drawPixmap(x, y, pixmap)
507 painter.setCompositionMode(comp_mode)
508 painter.end()
510 return image
512 def render_diff(self):
513 comp_mode = QtGui.QPainter.CompositionMode_Difference
514 return self.render_comp(comp_mode)
516 def render_xor(self):
517 comp_mode = QtGui.QPainter.CompositionMode_Xor
518 return self.render_comp(comp_mode)
520 def render_pixel_xor(self):
521 comp_mode = QtGui.QPainter.RasterOp_SourceXorDestination
522 return self.render_comp(comp_mode)
525 def create_image(width, height):
526 size = QtCore.QSize(width, height)
527 image = QtGui.QImage(size, QtGui.QImage.Format_ARGB32_Premultiplied)
528 image.fill(Qt.transparent)
529 return image
532 def create_painter(image):
533 painter = QtGui.QPainter(image)
534 painter.fillRect(image.rect(), Qt.transparent)
535 return painter
538 class Options(QtWidgets.QWidget):
539 """Provide the options widget used by the editor
541 Actions are registered on the parent widget.
545 # mode combobox indexes
546 SIDE_BY_SIDE = 0
547 DIFF = 1
548 XOR = 2
549 PIXEL_XOR = 3
551 def __init__(self, parent):
552 super(Options, self).__init__(parent)
553 self.widget = parent
554 self.ignore_space_at_eol = self.add_option(
555 N_('Ignore changes in whitespace at EOL'))
557 self.ignore_space_change = self.add_option(
558 N_('Ignore changes in amount of whitespace'))
560 self.ignore_all_space = self.add_option(
561 N_('Ignore all whitespace'))
563 self.function_context = self.add_option(
564 N_('Show whole surrounding functions of changes'))
566 self.show_line_numbers = self.add_option(
567 N_('Show line numbers'))
569 self.options = options = qtutils.create_action_button(
570 tooltip=N_('Diff Options'), icon=icons.configure())
571 qtutils.hide_button_menu_indicator(options)
573 self.image_mode = qtutils.combo([
574 N_('Side by side'),
575 N_('Diff'),
576 N_('XOR'),
577 N_('Pixel XOR'),
580 self.zoom_factors = (
581 (N_('Zoom to Fit'), 0.0),
582 (N_('25%'), 0.25),
583 (N_('50%'), 0.5),
584 (N_('100%'), 1.0),
585 (N_('200%'), 2.0),
586 (N_('400%'), 4.0),
587 (N_('800%'), 8.0),
589 zoom_modes = [factor[0] for factor in self.zoom_factors]
590 self.zoom_mode = qtutils.combo(zoom_modes, parent=self)
592 self.menu = menu = qtutils.create_menu(N_('Diff Options'), options)
593 options.setMenu(menu)
595 menu.addAction(self.ignore_space_at_eol)
596 menu.addAction(self.ignore_space_change)
597 menu.addAction(self.ignore_all_space)
598 menu.addAction(self.show_line_numbers)
599 menu.addAction(self.function_context)
601 layout = qtutils.hbox(
602 defs.no_margin, defs.no_spacing,
603 self.image_mode, defs.button_spacing, self.zoom_mode, options)
604 self.setLayout(layout)
606 self.image_mode.setFocusPolicy(Qt.NoFocus)
607 self.zoom_mode.setFocusPolicy(Qt.NoFocus)
608 self.options.setFocusPolicy(Qt.NoFocus)
609 self.setFocusPolicy(Qt.NoFocus)
611 def set_diff_type(self, diff_type):
612 is_text = diff_type == 'text'
613 is_image = diff_type == 'image'
614 self.options.setVisible(is_text)
615 self.image_mode.setVisible(is_image)
616 self.zoom_mode.setVisible(is_image)
618 def add_option(self, title):
619 action = qtutils.add_action(self, title, self.update_options)
620 action.setCheckable(True)
621 return action
623 def update_options(self):
624 space_at_eol = get(self.ignore_space_at_eol)
625 space_change = get(self.ignore_space_change)
626 all_space = get(self.ignore_all_space)
627 function_context = get(self.function_context)
628 gitcmds.update_diff_overrides(
629 space_at_eol, space_change, all_space, function_context)
630 self.widget.update_options()
633 class DiffEditor(DiffTextEdit):
635 up = Signal()
636 down = Signal()
637 options_changed = Signal()
638 updated = Signal()
639 diff_text_updated = Signal(object)
641 def __init__(self, context, options, parent):
642 DiffTextEdit.__init__(self, context, parent, numbers=True)
643 self.context = context
644 self.model = model = context.model
645 self.selection_model = selection_model = context.selection
647 # "Diff Options" tool menu
648 self.options = options
649 self.action_apply_selection = qtutils.add_action(
650 self, 'Apply', self.apply_selection, hotkeys.STAGE_DIFF)
652 self.action_revert_selection = qtutils.add_action(
653 self, 'Revert', self.revert_selection, hotkeys.REVERT)
654 self.action_revert_selection.setIcon(icons.undo())
656 self.launch_editor = actions.launch_editor_at_line(
657 context, self, *hotkeys.ACCEPT)
658 self.launch_difftool = actions.launch_difftool(context, self)
659 self.stage_or_unstage = actions.stage_or_unstage(context, self)
661 # Emit up/down signals so that they can be routed by the main widget
662 self.move_up = actions.move_up(self)
663 self.move_down = actions.move_down(self)
665 diff_text_updated = model.message_diff_text_updated
666 model.add_observer(diff_text_updated, self.diff_text_updated.emit)
667 self.diff_text_updated.connect(self.set_diff, type=Qt.QueuedConnection)
669 selection_model.add_observer(
670 selection_model.message_selection_changed, self.updated.emit)
671 self.updated.connect(self.refresh, type=Qt.QueuedConnection)
672 # Update the selection model when the cursor changes
673 self.cursorPositionChanged.connect(self._update_line_number)
675 def refresh(self):
676 enabled = False
677 s = self.selection_model.selection()
678 model = self.model
679 if s.modified and model.stageable():
680 if s.modified[0] in model.submodules:
681 pass
682 elif s.modified[0] not in model.unstaged_deleted:
683 enabled = True
684 self.action_revert_selection.setEnabled(enabled)
686 def enable_line_numbers(self, enabled):
687 """Enable/disable the diff line number display"""
688 self.numbers.setVisible(enabled)
689 self.options.show_line_numbers.setChecked(enabled)
691 def show_line_numbers(self):
692 """Return True if we should show line numbers"""
693 return get(self.options.show_line_numbers)
695 def update_options(self):
696 self.numbers.setVisible(self.show_line_numbers())
697 self.options_changed.emit()
699 # Qt overrides
700 def contextMenuEvent(self, event):
701 """Create the context menu for the diff display."""
702 menu = qtutils.create_menu(N_('Actions'), self)
703 context = self.context
704 model = self.model
705 s = self.selection_model.selection()
706 filename = self.selection_model.filename()
708 if model.stageable() or model.unstageable():
709 if model.stageable():
710 self.stage_or_unstage.setText(N_('Stage'))
711 else:
712 self.stage_or_unstage.setText(N_('Unstage'))
713 menu.addAction(self.stage_or_unstage)
715 if s.modified and model.stageable():
716 item = s.modified[0]
717 if item in model.submodules:
718 path = core.abspath(item)
719 action = menu.addAction(
720 icons.add(), cmds.Stage.name(),
721 cmds.run(cmds.Stage, context, s.modified))
722 action.setShortcut(hotkeys.STAGE_SELECTION)
723 menu.addAction(icons.cola(), N_('Launch git-cola'),
724 cmds.run(cmds.OpenRepo, context, path))
725 elif item not in model.unstaged_deleted:
726 if self.has_selection():
727 apply_text = N_('Stage Selected Lines')
728 revert_text = N_('Revert Selected Lines...')
729 else:
730 apply_text = N_('Stage Diff Hunk')
731 revert_text = N_('Revert Diff Hunk...')
733 self.action_apply_selection.setText(apply_text)
734 self.action_apply_selection.setIcon(icons.add())
736 self.action_revert_selection.setText(revert_text)
738 menu.addAction(self.action_apply_selection)
739 menu.addAction(self.action_revert_selection)
741 if s.staged and model.unstageable():
742 item = s.staged[0]
743 if item in model.submodules:
744 path = core.abspath(item)
745 action = menu.addAction(
746 icons.remove(), cmds.Unstage.name(),
747 cmds.run(cmds.Unstage, context, s.staged))
748 action.setShortcut(hotkeys.STAGE_SELECTION)
749 menu.addAction(icons.cola(), N_('Launch git-cola'),
750 cmds.run(cmds.OpenRepo, context, path))
751 elif item not in model.staged_deleted:
752 if self.has_selection():
753 apply_text = N_('Unstage Selected Lines')
754 else:
755 apply_text = N_('Unstage Diff Hunk')
757 self.action_apply_selection.setText(apply_text)
758 self.action_apply_selection.setIcon(icons.remove())
759 menu.addAction(self.action_apply_selection)
761 if model.stageable() or model.unstageable():
762 # Do not show the "edit" action when the file does not exist.
763 # Untracked files exist by definition.
764 if filename and core.exists(filename):
765 menu.addSeparator()
766 menu.addAction(self.launch_editor)
768 # Removed files can still be diffed.
769 menu.addAction(self.launch_difftool)
771 # Add the Previous/Next File actions, which improves discoverability
772 # of their associated shortcuts
773 menu.addSeparator()
774 menu.addAction(self.move_up)
775 menu.addAction(self.move_down)
777 menu.addSeparator()
778 action = menu.addAction(icons.copy(), N_('Copy'), self.copy)
779 action.setShortcut(QtGui.QKeySequence.Copy)
781 action = menu.addAction(icons.select_all(), N_('Select All'),
782 self.selectAll)
783 action.setShortcut(QtGui.QKeySequence.SelectAll)
784 menu.exec_(self.mapToGlobal(event.pos()))
786 def mousePressEvent(self, event):
787 if event.button() == Qt.RightButton:
788 # Intercept right-click to move the cursor to the current position.
789 # setTextCursor() clears the selection so this is only done when
790 # nothing is selected.
791 if not self.has_selection():
792 cursor = self.cursorForPosition(event.pos())
793 self.setTextCursor(cursor)
795 return super(DiffEditor, self).mousePressEvent(event)
797 def setPlainText(self, text):
798 """setPlainText(str) while retaining scrollbar positions"""
799 model = self.model
800 mode = model.mode
801 highlight = mode not in (model.mode_none, model.mode_untracked)
802 self.highlighter.set_enabled(highlight)
804 scrollbar = self.verticalScrollBar()
805 if scrollbar:
806 scrollvalue = get(scrollbar)
807 else:
808 scrollvalue = None
810 if text is None:
811 return
813 DiffTextEdit.setPlainText(self, text)
815 if scrollbar and scrollvalue is not None:
816 scrollbar.setValue(scrollvalue)
818 def selected_lines(self):
819 cursor = self.textCursor()
820 selection_start = cursor.selectionStart()
821 selection_end = max(selection_start, cursor.selectionEnd() - 1)
823 first_line_idx = -1
824 last_line_idx = -1
825 line_idx = 0
826 line_start = 0
828 for line_idx, line in enumerate(get(self).splitlines()):
829 line_end = line_start + len(line)
830 if line_start <= selection_start <= line_end:
831 first_line_idx = line_idx
832 if line_start <= selection_end <= line_end:
833 last_line_idx = line_idx
834 break
835 line_start = line_end + 1
837 if first_line_idx == -1:
838 first_line_idx = line_idx
840 if last_line_idx == -1:
841 last_line_idx = line_idx
843 return first_line_idx, last_line_idx
845 def apply_selection(self):
846 model = self.model
847 s = self.selection_model.single_selection()
848 if model.stageable() and s.modified:
849 self.process_diff_selection()
850 elif model.unstageable():
851 self.process_diff_selection(reverse=True)
853 def revert_selection(self):
854 """Destructively revert selected lines or hunk from a worktree file."""
856 if self.has_selection():
857 title = N_('Revert Selected Lines?')
858 ok_text = N_('Revert Selected Lines')
859 else:
860 title = N_('Revert Diff Hunk?')
861 ok_text = N_('Revert Diff Hunk')
863 if not Interaction.confirm(
864 title,
865 N_('This operation drops uncommitted changes.\n'
866 'These changes cannot be recovered.'),
867 N_('Revert the uncommitted changes?'),
868 ok_text, default=True, icon=icons.undo()):
869 return
870 self.process_diff_selection(reverse=True, apply_to_worktree=True)
872 def process_diff_selection(self, reverse=False, apply_to_worktree=False):
873 """Implement un/staging of the selected line(s) or hunk."""
874 if self.selection_model.is_empty():
875 return
876 context = self.context
877 first_line_idx, last_line_idx = self.selected_lines()
878 cmds.do(cmds.ApplyDiffSelection, context,
879 first_line_idx, last_line_idx,
880 self.has_selection(), reverse, apply_to_worktree)
882 def _update_line_number(self):
883 """Update the selection model when the cursor changes"""
884 self.selection_model.line_number = self.numbers.current_line()
887 class DiffWidget(QtWidgets.QWidget):
889 def __init__(self, context, notifier, parent, is_commit=False):
890 QtWidgets.QWidget.__init__(self, parent)
892 self.context = context
893 self.oid = 'HEAD'
895 author_font = QtGui.QFont(self.font())
896 author_font.setPointSize(int(author_font.pointSize() * 1.1))
898 summary_font = QtGui.QFont(author_font)
899 summary_font.setWeight(QtGui.QFont.Bold)
901 policy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.MinimumExpanding,
902 QtWidgets.QSizePolicy.Minimum)
904 self.gravatar_label = gravatar.GravatarLabel()
906 self.author_label = TextLabel()
907 self.author_label.setTextFormat(Qt.RichText)
908 self.author_label.setFont(author_font)
909 self.author_label.setSizePolicy(policy)
910 self.author_label.setAlignment(Qt.AlignBottom)
911 self.author_label.elide()
913 self.date_label = TextLabel()
914 self.date_label.setTextFormat(Qt.PlainText)
915 self.date_label.setSizePolicy(policy)
916 self.date_label.setAlignment(Qt.AlignTop)
917 self.date_label.hide()
919 self.summary_label = TextLabel()
920 self.summary_label.setTextFormat(Qt.PlainText)
921 self.summary_label.setFont(summary_font)
922 self.summary_label.setSizePolicy(policy)
923 self.summary_label.setAlignment(Qt.AlignTop)
924 self.summary_label.elide()
926 self.oid_label = TextLabel()
927 self.oid_label.setTextFormat(Qt.PlainText)
928 self.oid_label.setSizePolicy(policy)
929 self.oid_label.setAlignment(Qt.AlignTop)
930 self.oid_label.elide()
932 self.diff = DiffTextEdit(
933 context, self, is_commit=is_commit, whitespace=False)
935 self.info_layout = qtutils.vbox(defs.no_margin, defs.no_spacing,
936 self.author_label, self.date_label,
937 self.summary_label, self.oid_label)
939 self.logo_layout = qtutils.hbox(defs.no_margin, defs.button_spacing,
940 self.gravatar_label, self.info_layout)
941 self.logo_layout.setContentsMargins(defs.margin, 0, defs.margin, 0)
943 self.main_layout = qtutils.vbox(defs.no_margin, defs.spacing,
944 self.logo_layout, self.diff)
945 self.setLayout(self.main_layout)
947 notifier.add_observer(COMMITS_SELECTED, self.commits_selected)
948 notifier.add_observer(FILES_SELECTED, self.files_selected)
949 self.set_tabwidth(prefs.tabwidth(context))
951 def set_tabwidth(self, width):
952 self.diff.set_tabwidth(width)
954 def set_diff_oid(self, oid, filename=None):
955 context = self.context
956 self.diff.save_scrollbar()
957 self.diff.set_loading_message()
958 task = DiffInfoTask(context, oid, filename, self)
959 self.context.runtask.start(task, result=self.set_diff)
961 def commits_selected(self, commits):
962 if len(commits) != 1:
963 return
964 commit = commits[0]
965 oid = self.oid = commit.oid
966 email = commit.email or ''
967 summary = commit.summary or ''
968 author = commit.author or ''
970 self.set_details(oid, author, email, '', summary)
971 self.set_diff_oid(oid)
973 def set_diff(self, diff):
974 self.diff.set_diff(diff)
976 def set_details(self, oid, author, email, date, summary):
977 template_args = {
978 'author': author,
979 'email': email,
980 'summary': summary
982 author_text = ("""%(author)s &lt;"""
983 """<a href="mailto:%(email)s">"""
984 """%(email)s</a>&gt;"""
985 % template_args)
986 author_template = '%(author)s <%(email)s>' % template_args
988 self.date_label.set_text(date)
989 self.date_label.setVisible(bool(date))
990 self.oid_label.set_text(oid)
991 self.author_label.set_template(author_text, author_template)
992 self.summary_label.set_text(summary)
993 self.gravatar_label.set_email(email)
995 def files_selected(self, filenames):
996 if not filenames:
997 return
998 self.set_diff_oid(self.oid, filenames[0])
1001 class TextLabel(QtWidgets.QLabel):
1003 def __init__(self, parent=None):
1004 QtWidgets.QLabel.__init__(self, parent)
1005 self.setTextInteractionFlags(Qt.TextSelectableByMouse |
1006 Qt.LinksAccessibleByMouse)
1007 self._display = ''
1008 self._template = ''
1009 self._text = ''
1010 self._elide = False
1011 self._metrics = QtGui.QFontMetrics(self.font())
1012 self.setOpenExternalLinks(True)
1014 def elide(self):
1015 self._elide = True
1017 def set_text(self, text):
1018 self.set_template(text, text)
1020 def set_template(self, text, template):
1021 self._display = text
1022 self._text = text
1023 self._template = template
1024 self.update_text(self.width())
1025 self.setText(self._display)
1027 def update_text(self, width):
1028 self._display = self._text
1029 if not self._elide:
1030 return
1031 text = self._metrics.elidedText(self._template,
1032 Qt.ElideRight, width-2)
1033 if text != self._template:
1034 self._display = text
1036 # Qt overrides
1037 def setFont(self, font):
1038 self._metrics = QtGui.QFontMetrics(font)
1039 QtWidgets.QLabel.setFont(self, font)
1041 def resizeEvent(self, event):
1042 if self._elide:
1043 self.update_text(event.size().width())
1044 block = self.blockSignals(True)
1045 self.setText(self._display)
1046 self.blockSignals(block)
1047 QtWidgets.QLabel.resizeEvent(self, event)
1050 class DiffInfoTask(qtutils.Task):
1052 def __init__(self, context, oid, filename, parent):
1053 qtutils.Task.__init__(self, parent)
1054 self.context = context
1055 self.oid = oid
1056 self.filename = filename
1058 def task(self):
1059 context = self.context
1060 oid = self.oid
1061 return gitcmds.diff_info(context, oid, filename=self.filename)