status: use Italics instead of Bold-with-background for headers
[git-cola.git] / cola / widgets / diff.py
blobb6bbd12cfb1dd7889b96b5305e4b60d83ba3d727
1 from __future__ import division, absolute_import, unicode_literals
3 import re
5 from PyQt4 import QtCore
6 from PyQt4 import QtGui
7 from PyQt4.QtCore import Qt, SIGNAL
9 from cola import actions
10 from cola import cmds
11 from cola import core
12 from cola import gitcfg
13 from cola import gitcmds
14 from cola import gravatar
15 from cola import hotkeys
16 from cola import icons
17 from cola import qtutils
18 from cola.i18n import N_
19 from cola.models import main
20 from cola.models import selection
21 from cola.qtutils import add_action
22 from cola.qtutils import create_action_button
23 from cola.qtutils import create_menu
24 from cola.qtutils import RGB, make_format
25 from cola.widgets import defs
26 from cola.widgets.text import VimMonoTextView
27 from cola.compat import ustr
30 COMMITS_SELECTED = 'COMMITS_SELECTED'
31 FILES_SELECTED = 'FILES_SELECTED'
34 class DiffSyntaxHighlighter(QtGui.QSyntaxHighlighter):
35 """Implements the diff syntax highlighting"""
37 INITIAL_STATE = -1
38 DIFFSTAT_STATE = 0
39 DIFF_FILE_HEADER_STATE = 1
40 DIFF_STATE = 2
41 SUBMODULE_STATE = 3
43 DIFF_FILE_HEADER_START_RGX = re.compile(r'diff --git a/.* b/.*')
44 DIFF_HUNK_HEADER_RGX = re.compile(r'(?:@@ -[0-9,]+ \+[0-9,]+ @@)|'
45 r'(?:@@@ (?:-[0-9,]+ ){2}\+[0-9,]+ @@@)')
46 BAD_WHITESPACE_RGX = re.compile(r'\s+$')
48 def __init__(self, doc, whitespace=True):
49 QtGui.QSyntaxHighlighter.__init__(self, doc)
50 self.whitespace = whitespace
51 self.enabled = True
53 cfg = gitcfg.current()
54 self.color_text = RGB(cfg.color('text', '030303'))
55 self.color_add = RGB(cfg.color('add', 'd2ffe4'))
56 self.color_remove = RGB(cfg.color('remove', 'fee0e4'))
57 self.color_header = RGB(cfg.color('header', 'bbbbbb'))
59 self.diff_header_fmt = make_format(fg=self.color_header)
60 self.bold_diff_header_fmt = make_format(fg=self.color_header,
61 bold=True)
63 self.diff_add_fmt = make_format(fg=self.color_text,
64 bg=self.color_add)
65 self.diff_remove_fmt = make_format(fg=self.color_text,
66 bg=self.color_remove)
67 self.bad_whitespace_fmt = make_format(bg=Qt.red)
69 def set_enabled(self, enabled):
70 self.enabled = enabled
72 def highlightBlock(self, text):
73 if not self.enabled:
74 return
76 text = ustr(text)
77 if not text:
78 return
80 state = self.previousBlockState()
81 if state == self.INITIAL_STATE:
82 if text.startswith('Submodule '):
83 state = self.SUBMODULE_STATE
84 else:
85 state = self.DIFFSTAT_STATE
87 if state == self.DIFFSTAT_STATE:
88 if self.DIFF_FILE_HEADER_START_RGX.match(text):
89 state = self.DIFF_FILE_HEADER_STATE
90 self.setFormat(0, len(text), self.diff_header_fmt)
91 elif self.DIFF_HUNK_HEADER_RGX.match(text):
92 state = self.DIFF_STATE
93 self.setFormat(0, len(text), self.bold_diff_header_fmt)
94 elif '|' in text:
95 i = text.index('|')
96 self.setFormat(0, i, self.bold_diff_header_fmt)
97 self.setFormat(i, len(text) - i, self.diff_header_fmt)
98 else:
99 self.setFormat(0, len(text), self.diff_header_fmt)
100 elif state == self.DIFF_FILE_HEADER_STATE:
101 if self.DIFF_HUNK_HEADER_RGX.match(text):
102 state = self.DIFF_STATE
103 self.setFormat(0, len(text), self.bold_diff_header_fmt)
104 else:
105 self.setFormat(0, len(text), self.diff_header_fmt)
106 elif state == self.DIFF_STATE:
107 if self.DIFF_FILE_HEADER_START_RGX.match(text):
108 state = self.DIFF_FILE_HEADER_STATE
109 self.setFormat(0, len(text), self.diff_header_fmt)
110 elif self.DIFF_HUNK_HEADER_RGX.match(text):
111 self.setFormat(0, len(text), self.bold_diff_header_fmt)
112 elif text.startswith('-'):
113 self.setFormat(0, len(text), self.diff_remove_fmt)
114 elif text.startswith('+'):
115 self.setFormat(0, len(text), self.diff_add_fmt)
116 if self.whitespace:
117 m = self.BAD_WHITESPACE_RGX.search(text)
118 if m is not None:
119 i = m.start()
120 self.setFormat(i, len(text) - i,
121 self.bad_whitespace_fmt)
123 self.setCurrentBlockState(state)
126 class DiffTextEdit(VimMonoTextView):
127 def __init__(self, parent, whitespace=True):
129 VimMonoTextView.__init__(self, parent)
130 # Diff/patch syntax highlighter
131 self.highlighter = DiffSyntaxHighlighter(self.document(),
132 whitespace=whitespace)
134 class DiffEditorWidget(QtGui.QWidget):
136 def __init__(self, parent=None):
137 QtGui.QWidget.__init__(self, parent)
139 self.editor = DiffEditor(self, parent.titleBarWidget())
140 self.main_layout = qtutils.vbox(defs.no_margin, defs.spacing,
141 self.editor)
142 self.setLayout(self.main_layout)
143 self.setFocusProxy(self.editor)
146 class DiffEditor(DiffTextEdit):
148 def __init__(self, parent, titlebar):
149 DiffTextEdit.__init__(self, parent)
150 self.model = model = main.model()
152 # "Diff Options" tool menu
153 self.diff_ignore_space_at_eol_action = add_action(
154 self, N_('Ignore changes in whitespace at EOL'),
155 self._update_diff_opts)
156 self.diff_ignore_space_at_eol_action.setCheckable(True)
158 self.diff_ignore_space_change_action = add_action(
159 self, N_('Ignore changes in amount of whitespace'),
160 self._update_diff_opts)
161 self.diff_ignore_space_change_action.setCheckable(True)
163 self.diff_ignore_all_space_action = add_action(
164 self, N_('Ignore all whitespace'), self._update_diff_opts)
165 self.diff_ignore_all_space_action.setCheckable(True)
167 self.diff_function_context_action = add_action(
168 self, N_('Show whole surrounding functions of changes'),
169 self._update_diff_opts)
170 self.diff_function_context_action.setCheckable(True)
172 self.diffopts_button = create_action_button(
173 tooltip=N_('Diff Options'), icon=icons.configure())
174 self.diffopts_menu = create_menu(N_('Diff Options'),
175 self.diffopts_button)
177 self.diffopts_menu.addAction(self.diff_ignore_space_at_eol_action)
178 self.diffopts_menu.addAction(self.diff_ignore_space_change_action)
179 self.diffopts_menu.addAction(self.diff_ignore_all_space_action)
180 self.diffopts_menu.addAction(self.diff_function_context_action)
181 self.diffopts_button.setMenu(self.diffopts_menu)
182 qtutils.hide_button_menu_indicator(self.diffopts_button)
184 titlebar.add_corner_widget(self.diffopts_button)
186 self.action_apply_selection = qtutils.add_action(
187 self, 'Apply', self.apply_selection, hotkeys.STAGE_DIFF)
189 self.action_revert_selection = qtutils.add_action(
190 self, 'Revert', self.revert_selection, hotkeys.REVERT)
191 self.action_revert_selection.setIcon(icons.undo())
193 self.launch_editor = actions.launch_editor(self, 'Return', 'Enter')
194 self.launch_difftool = actions.launch_difftool(self)
195 self.stage_or_unstage = actions.stage_or_unstage(self)
197 # Emit up/down signals so that they can be routed by the main widget
198 self.move_down = actions.move_down(self)
199 self.move_up = actions.move_up(self)
201 model.add_observer(model.message_diff_text_changed, self._emit_text)
203 self.selection_model = selection_model = selection.selection_model()
204 selection_model.add_observer(selection_model.message_selection_changed,
205 self._update)
206 self.connect(self, SIGNAL('update()'),
207 self._update_callback, Qt.QueuedConnection)
209 self.connect(self, SIGNAL('set_text(PyQt_PyObject)'), self.setPlainText)
211 def _update(self):
212 self.emit(SIGNAL('update()'))
214 def _update_callback(self):
215 enabled = False
216 s = self.selection_model.selection()
217 if s.modified and self.model.stageable():
218 if s.modified[0] in self.model.submodules:
219 pass
220 elif s.modified[0] not in main.model().unstaged_deleted:
221 enabled = True
222 self.action_revert_selection.setEnabled(enabled)
224 def _emit_text(self, text):
225 self.emit(SIGNAL('set_text(PyQt_PyObject)'), text)
227 def _update_diff_opts(self):
228 space_at_eol = self.diff_ignore_space_at_eol_action.isChecked()
229 space_change = self.diff_ignore_space_change_action.isChecked()
230 all_space = self.diff_ignore_all_space_action.isChecked()
231 function_context = self.diff_function_context_action.isChecked()
233 gitcmds.update_diff_overrides(space_at_eol,
234 space_change,
235 all_space,
236 function_context)
237 self.emit(SIGNAL('diff_options_updated()'))
239 # Qt overrides
240 def contextMenuEvent(self, event):
241 """Create the context menu for the diff display."""
242 menu = QtGui.QMenu(self)
243 s = selection.selection()
244 filename = selection.filename()
246 if self.model.stageable() or self.model.unstageable():
247 if self.model.stageable():
248 self.stage_or_unstage.setText(N_('Stage'))
249 else:
250 self.stage_or_unstage.setText(N_('Unstage'))
251 menu.addAction(self.stage_or_unstage)
253 if s.modified and self.model.stageable():
254 if s.modified[0] in main.model().submodules:
255 action = menu.addAction(icons.add(), cmds.Stage.name(),
256 cmds.run(cmds.Stage, s.modified))
257 action.setShortcut(hotkeys.STAGE_SELECTION)
258 menu.addAction(icons.cola(), N_('Launch git-cola'),
259 cmds.run(cmds.OpenRepo,
260 core.abspath(s.modified[0])))
261 elif s.modified[0] not in main.model().unstaged_deleted:
262 if self.has_selection():
263 apply_text = N_('Stage Selected Lines')
264 revert_text = N_('Revert Selected Lines...')
265 else:
266 apply_text = N_('Stage Diff Hunk')
267 revert_text = N_('Revert Diff Hunk...')
269 self.action_apply_selection.setText(apply_text)
270 self.action_apply_selection.setIcon(icons.add())
272 self.action_revert_selection.setText(revert_text)
274 menu.addAction(self.action_apply_selection)
275 menu.addAction(self.action_revert_selection)
277 if s.staged and self.model.unstageable():
278 if s.staged[0] in main.model().submodules:
279 action = menu.addAction(icons.remove(), cmds.Unstage.name(),
280 cmds.do(cmds.Unstage, s.staged))
281 action.setShortcut(hotkeys.STAGE_SELECTION)
282 menu.addAction(icons.cola(), N_('Launch git-cola'),
283 cmds.do(cmds.OpenRepo,
284 core.abspath(s.staged[0])))
285 elif s.staged[0] not in main.model().staged_deleted:
286 if self.has_selection():
287 apply_text = N_('Unstage Selected Lines')
288 else:
289 apply_text = N_('Unstage Diff Hunk')
291 self.action_apply_selection.setText(apply_text)
292 self.action_apply_selection.setIcon(icons.remove())
293 menu.addAction(self.action_apply_selection)
295 if self.model.stageable() or self.model.unstageable():
296 # Do not show the "edit" action when the file does not exist.
297 # Untracked files exist by definition.
298 if filename and core.exists(filename):
299 menu.addSeparator()
300 menu.addAction(self.launch_editor)
302 # Removed files can still be diffed.
303 menu.addAction(self.launch_difftool)
305 # Add the Previous/Next File actions, which improves discoverability
306 # of their associated shortcuts
307 menu.addSeparator()
308 menu.addAction(self.move_up)
309 menu.addAction(self.move_down)
311 menu.addSeparator()
312 action = menu.addAction(icons.copy(), N_('Copy'), self.copy)
313 action.setShortcut(QtGui.QKeySequence.Copy)
315 action = menu.addAction(icons.select_all(), N_('Select All'),
316 self.selectAll)
317 action.setShortcut(QtGui.QKeySequence.SelectAll)
318 menu.exec_(self.mapToGlobal(event.pos()))
320 def wheelEvent(self, event):
321 if event.modifiers() & Qt.ControlModifier:
322 # Intercept the Control modifier to not resize the text
323 # when doing control+mousewheel
324 event.accept()
325 event = QtGui.QWheelEvent(event.pos(), event.delta(),
326 Qt.NoButton,
327 Qt.NoModifier,
328 event.orientation())
330 return DiffTextEdit.wheelEvent(self, event)
332 def mousePressEvent(self, event):
333 if event.button() == Qt.RightButton:
334 # Intercept right-click to move the cursor to the current position.
335 # setTextCursor() clears the selection so this is only done when
336 # nothing is selected.
337 if not self.has_selection():
338 cursor = self.cursorForPosition(event.pos())
339 self.setTextCursor(cursor)
341 return DiffTextEdit.mousePressEvent(self, event)
343 def setPlainText(self, text):
344 """setPlainText(str) while retaining scrollbar positions"""
345 mode = self.model.mode
346 highlight = (mode != self.model.mode_none and
347 mode != self.model.mode_untracked)
348 self.highlighter.set_enabled(highlight)
350 scrollbar = self.verticalScrollBar()
351 if scrollbar:
352 scrollvalue = scrollbar.value()
353 else:
354 scrollvalue = None
356 if text is None:
357 return
359 offset, selection_text = self.offset_and_selection()
360 old_text = ustr(self.toPlainText())
362 DiffTextEdit.setPlainText(self, text)
364 if selection_text and selection_text in text:
365 # If the old selection exists in the new text then re-select it.
366 idx = text.index(selection_text)
367 cursor = self.textCursor()
368 cursor.setPosition(idx)
369 cursor.setPosition(idx + len(selection_text),
370 QtGui.QTextCursor.KeepAnchor)
371 self.setTextCursor(cursor)
373 elif text == old_text:
374 # Otherwise, if the text is identical and there is no selection
375 # then restore the cursor position.
376 cursor = self.textCursor()
377 cursor.setPosition(offset)
378 self.setTextCursor(cursor)
379 else:
380 # If none of the above applied then restore the cursor position.
381 position = max(0, min(offset, len(text) - 1))
382 cursor = self.textCursor()
383 cursor.setPosition(position)
384 cursor.movePosition(QtGui.QTextCursor.StartOfLine)
385 self.setTextCursor(cursor)
387 if scrollbar and scrollvalue is not None:
388 scrollbar.setValue(scrollvalue)
390 def has_selection(self):
391 return self.textCursor().hasSelection()
393 def offset_and_selection(self):
394 cursor = self.textCursor()
395 offset = cursor.selectionStart()
396 selection_text = ustr(cursor.selection().toPlainText())
397 return offset, selection_text
399 def selected_lines(self):
400 cursor = self.textCursor()
401 selection_start = cursor.selectionStart()
402 selection_end = cursor.selectionEnd()
404 line_start = 0
405 for line_idx, line in enumerate(ustr(self.toPlainText()).split('\n')):
406 line_end = line_start + len(line)
407 if line_start <= selection_start <= line_end:
408 first_line_idx = line_idx
409 if line_start <= selection_end <= line_end:
410 last_line_idx = line_idx
411 break
412 line_start = line_end + 1
414 return first_line_idx, last_line_idx
416 def apply_selection(self):
417 s = selection.single_selection()
418 if self.model.stageable() and s.modified:
419 self.process_diff_selection()
420 elif self.model.unstageable():
421 self.process_diff_selection(reverse=True)
423 def revert_selection(self):
424 """Destructively revert selected lines or hunk from a worktree file."""
426 if self.has_selection():
427 title = N_('Revert Selected Lines?')
428 ok_text = N_('Revert Selected Lines')
429 else:
430 title = N_('Revert Diff Hunk?')
431 ok_text = N_('Revert Diff Hunk')
433 if not qtutils.confirm(title,
434 N_('This operation drops uncommitted changes.\n'
435 'These changes cannot be recovered.'),
436 N_('Revert the uncommitted changes?'),
437 ok_text, default=True, icon=icons.undo()):
438 return
439 self.process_diff_selection(reverse=True, apply_to_worktree=True)
441 def process_diff_selection(self, reverse=False, apply_to_worktree=False):
442 """Implement un/staging of the selected line(s) or hunk."""
443 if selection.selection_model().is_empty():
444 return
445 first_line_idx, last_line_idx = self.selected_lines()
446 cmds.do(cmds.ApplyDiffSelection, first_line_idx, last_line_idx,
447 self.has_selection(), reverse, apply_to_worktree)
450 class DiffWidget(QtGui.QWidget):
452 def __init__(self, notifier, parent):
453 QtGui.QWidget.__init__(self, parent)
455 self.runtask = qtutils.RunTask(parent=self)
457 author_font = QtGui.QFont(self.font())
458 author_font.setPointSize(int(author_font.pointSize() * 1.1))
460 summary_font = QtGui.QFont(author_font)
461 summary_font.setWeight(QtGui.QFont.Bold)
463 policy = QtGui.QSizePolicy(QtGui.QSizePolicy.MinimumExpanding,
464 QtGui.QSizePolicy.Minimum)
466 self.gravatar_label = gravatar.GravatarLabel()
468 self.author_label = TextLabel()
469 self.author_label.setTextFormat(Qt.RichText)
470 self.author_label.setFont(author_font)
471 self.author_label.setSizePolicy(policy)
472 self.author_label.setAlignment(Qt.AlignBottom)
473 self.author_label.elide()
475 self.summary_label = TextLabel()
476 self.summary_label.setTextFormat(Qt.PlainText)
477 self.summary_label.setFont(summary_font)
478 self.summary_label.setSizePolicy(policy)
479 self.summary_label.setAlignment(Qt.AlignTop)
480 self.summary_label.elide()
482 self.sha1_label = TextLabel()
483 self.sha1_label.setTextFormat(Qt.PlainText)
484 self.sha1_label.setSizePolicy(policy)
485 self.sha1_label.setAlignment(Qt.AlignTop)
486 self.sha1_label.elide()
488 self.diff = DiffTextEdit(self, whitespace=False)
490 self.info_layout = qtutils.vbox(defs.no_margin, defs.no_spacing,
491 self.author_label, self.summary_label,
492 self.sha1_label)
494 self.logo_layout = qtutils.hbox(defs.no_margin, defs.button_spacing,
495 self.gravatar_label, self.info_layout)
496 self.logo_layout.setContentsMargins(defs.margin, 0, defs.margin, 0)
498 self.main_layout = qtutils.vbox(defs.no_margin, defs.spacing,
499 self.logo_layout, self.diff)
500 self.setLayout(self.main_layout)
502 notifier.add_observer(COMMITS_SELECTED, self.commits_selected)
503 notifier.add_observer(FILES_SELECTED, self.files_selected)
505 def set_diff_sha1(self, sha1, filename=None):
506 self.diff.setText('+++ ' + N_('Loading...'))
507 task = DiffInfoTask(sha1, filename, self)
508 task.connect(self.diff.setText)
509 self.runtask.start(task)
511 def commits_selected(self, commits):
512 if len(commits) != 1:
513 return
514 commit = commits[0]
515 self.sha1 = commit.sha1
517 email = commit.email or ''
518 summary = commit.summary or ''
519 author = commit.author or ''
521 template_args = {
522 'author': author,
523 'email': email,
524 'summary': summary
527 author_text = ("""%(author)s &lt;"""
528 """<a href="mailto:%(email)s">"""
529 """%(email)s</a>&gt;"""
530 % template_args)
532 author_template = '%(author)s <%(email)s>' % template_args
533 self.author_label.set_template(author_text, author_template)
534 self.summary_label.set_text(summary)
535 self.sha1_label.set_text(self.sha1)
537 self.set_diff_sha1(self.sha1)
538 self.gravatar_label.set_email(email)
540 def files_selected(self, filenames):
541 if not filenames:
542 return
543 self.set_diff_sha1(self.sha1, filenames[0])
546 class TextLabel(QtGui.QLabel):
548 def __init__(self, parent=None):
549 QtGui.QLabel.__init__(self, parent)
550 self.setTextInteractionFlags(Qt.TextSelectableByMouse |
551 Qt.LinksAccessibleByMouse)
552 self._display = ''
553 self._template = ''
554 self._text = ''
555 self._elide = False
556 self._metrics = QtGui.QFontMetrics(self.font())
557 self.setOpenExternalLinks(True)
559 def elide(self):
560 self._elide = True
562 def set_text(self, text):
563 self.set_template(text, text)
565 def set_template(self, text, template):
566 self._display = text
567 self._text = text
568 self._template = template
569 self.update_text(self.width())
570 self.setText(self._display)
572 def update_text(self, width):
573 self._display = self._text
574 if not self._elide:
575 return
576 text = self._metrics.elidedText(self._template,
577 Qt.ElideRight, width-2)
578 if ustr(text) != self._template:
579 self._display = text
581 # Qt overrides
582 def setFont(self, font):
583 self._metrics = QtGui.QFontMetrics(font)
584 QtGui.QLabel.setFont(self, font)
586 def resizeEvent(self, event):
587 if self._elide:
588 self.update_text(event.size().width())
589 block = self.blockSignals(True)
590 self.setText(self._display)
591 self.blockSignals(block)
592 QtGui.QLabel.resizeEvent(self, event)
595 class DiffInfoTask(qtutils.Task):
597 def __init__(self, sha1, filename, parent):
598 qtutils.Task.__init__(self, parent)
599 self.sha1 = sha1
600 self.filename = filename
602 def task(self):
603 return gitcmds.diff_info(self.sha1, filename=self.filename)