commitmsg: move the progress bar into the dock title
[git-cola.git] / cola / widgets / dag.py
blob3d597b2a78d2e10d4158889f6fe91d18a27f9f16
1 import collections
2 import itertools
3 import math
4 from functools import partial
6 from qtpy.QtCore import Qt
7 from qtpy.QtCore import Signal
8 from qtpy import QtCore
9 from qtpy import QtGui
10 from qtpy import QtWidgets
12 from ..compat import maxsize
13 from ..i18n import N_
14 from ..models import dag
15 from ..models import main
16 from ..qtutils import get
17 from .. import core
18 from .. import cmds
19 from .. import difftool
20 from .. import gitcmds
21 from .. import guicmds
22 from .. import hotkeys
23 from .. import icons
24 from .. import qtcompat
25 from .. import qtutils
26 from .. import utils
27 from . import archive
28 from . import browse
29 from . import completion
30 from . import createbranch
31 from . import createtag
32 from . import defs
33 from . import diff
34 from . import filelist
35 from . import standard
38 def git_dag(context, args=None, existing_view=None, show=True):
39 """Return a pre-populated git DAG widget."""
40 model = context.model
41 branch = model.currentbranch
42 # disambiguate between branch names and filenames by using '--'
43 branch_doubledash = (branch + ' --') if branch else ''
44 params = dag.DAG(branch_doubledash, 1000)
45 params.set_arguments(args)
47 if existing_view is None:
48 view = GitDAG(context, params)
49 else:
50 view = existing_view
51 view.set_params(params)
52 if params.ref:
53 view.display()
54 if show:
55 view.show()
56 return view
59 class FocusRedirectProxy:
60 """Redirect actions from the main widget to child widgets"""
62 def __init__(self, *widgets):
63 """Provide proxied widgets; the default widget must be first"""
64 self.widgets = widgets
65 self.default = widgets[0]
67 def __getattr__(self, name):
68 return lambda *args, **kwargs: self._forward_action(name, *args, **kwargs)
70 def _forward_action(self, name, *args, **kwargs):
71 """Forward the captured action to the focused or default widget"""
72 widget = QtWidgets.QApplication.focusWidget()
73 if widget in self.widgets and hasattr(widget, name):
74 func = getattr(widget, name)
75 else:
76 func = getattr(self.default, name)
78 return func(*args, **kwargs)
81 class ViewerMixin:
82 """Implementations must provide selected_items()"""
84 def __init__(self):
85 self.context = None # provided by implementation
86 self.selected = None
87 self.clicked = None
88 self.menu_actions = None # provided by implementation
90 def selected_item(self):
91 """Return the currently selected item"""
92 selected_items = self.selected_items()
93 if not selected_items:
94 return None
95 return selected_items[0]
97 def selected_oid(self):
98 """Return the currently selected commit object ID"""
99 item = self.selected_item()
100 if item is None:
101 result = None
102 else:
103 result = item.commit.oid
104 return result
106 def selected_oids(self):
107 """Return the currently selected comit object IDs"""
108 return [i.commit for i in self.selected_items()]
110 def clicked_oid(self):
111 """Return the clicked or selected commit object ID"""
112 if self.clicked:
113 return self.clicked.oid
114 return self.selected_oid()
116 def with_oid(self, func):
117 """Run an operation with a commit object ID"""
118 oid = self.clicked_oid()
119 if oid:
120 result = func(oid)
121 else:
122 result = None
123 return result
125 def with_selected_oid(self, func):
126 """Run an operation with a commit object ID"""
127 oid = self.selected_oid()
128 if oid:
129 result = func(oid)
130 else:
131 result = None
132 return result
134 def diff_selected_this(self):
135 """Diff the selected commit against the clicked commit"""
136 clicked_oid = self.clicked.oid
137 selected_oid = self.selected.oid
138 self.diff_commits.emit(selected_oid, clicked_oid)
140 def diff_this_selected(self):
141 """Diff the clicked commit against the selected commit"""
142 clicked_oid = self.clicked.oid
143 selected_oid = self.selected.oid
144 self.diff_commits.emit(clicked_oid, selected_oid)
146 def cherry_pick(self):
147 """Cherry-pick a commit using git cherry-pick"""
148 context = self.context
149 self.with_oid(lambda oid: cmds.do(cmds.CherryPick, context, [oid]))
151 def revert(self):
152 """Revert a commit using git revert"""
153 context = self.context
154 self.with_oid(lambda oid: cmds.do(cmds.Revert, context, oid))
156 def copy_to_clipboard(self):
157 """Copy the current commit object ID to the clipboard"""
158 self.with_oid(qtutils.set_clipboard)
160 def checkout_branch(self):
161 """Checkout the clicked/selected branch"""
162 branches = []
163 clicked = self.clicked
164 selected = self.selected_item()
165 if clicked:
166 branches.extend(clicked.branches)
167 if selected:
168 branches.extend(selected.commit.branches)
169 if not branches:
170 return
171 guicmds.checkout_branch(self.context, default=branches[0])
173 def create_branch(self):
174 """Create a branch at the selected commit"""
175 context = self.context
176 create_new_branch = partial(createbranch.create_new_branch, context)
177 self.with_oid(lambda oid: create_new_branch(revision=oid))
179 def create_tag(self):
180 """Create a tag at the selected commit"""
181 context = self.context
182 self.with_oid(lambda oid: createtag.create_tag(context, ref=oid))
184 def create_tarball(self):
185 """Create a tarball from the selected commit"""
186 context = self.context
187 self.with_oid(lambda oid: archive.show_save_dialog(context, oid, parent=self))
189 def show_diff(self):
190 """Show the diff for the selected commit"""
191 context = self.context
192 self.with_oid(
193 lambda oid: difftool.diff_expression(
194 context, self, oid + '^!', hide_expr=False, focus_tree=True
198 def show_dir_diff(self):
199 """Show a full directory diff for the selected commit"""
200 context = self.context
201 self.with_oid(
202 lambda oid: difftool.difftool_launch(
203 context, left=oid, left_take_magic=True, dir_diff=True
207 def rebase_to_commit(self):
208 """Rebase the current branch to the selected commit"""
209 context = self.context
210 self.with_oid(lambda oid: cmds.do(cmds.Rebase, context, upstream=oid))
212 def reset_mixed(self):
213 """Reset the repository using git reset --mixed"""
214 context = self.context
215 self.with_oid(lambda oid: cmds.do(cmds.ResetMixed, context, ref=oid))
217 def reset_keep(self):
218 """Reset the repository using git reset --keep"""
219 context = self.context
220 self.with_oid(lambda oid: cmds.do(cmds.ResetKeep, context, ref=oid))
222 def reset_merge(self):
223 """Reset the repository using git reset --merge"""
224 context = self.context
225 self.with_oid(lambda oid: cmds.do(cmds.ResetMerge, context, ref=oid))
227 def reset_soft(self):
228 """Reset the repository using git reset --soft"""
229 context = self.context
230 self.with_oid(lambda oid: cmds.do(cmds.ResetSoft, context, ref=oid))
232 def reset_hard(self):
233 """Reset the repository using git reset --hard"""
234 context = self.context
235 self.with_oid(lambda oid: cmds.do(cmds.ResetHard, context, ref=oid))
237 def restore_worktree(self):
238 """Reset the worktree contents from the selected commit"""
239 context = self.context
240 self.with_oid(lambda oid: cmds.do(cmds.RestoreWorktree, context, ref=oid))
242 def checkout_detached(self):
243 """Checkout a commit using an anonymous detached HEAD"""
244 context = self.context
245 self.with_oid(lambda oid: cmds.do(cmds.Checkout, context, [oid]))
247 def save_blob_dialog(self):
248 """Save a file blob from the selected commit"""
249 context = self.context
250 self.with_oid(lambda oid: browse.BrowseBranch.browse(context, oid))
252 def update_menu_actions(self, event):
253 """Update menu actions to reflect the selection state"""
254 selected_items = self.selected_items()
255 selected_item = self.selected_item()
256 item = self.itemAt(event.pos())
257 if item is None:
258 self.clicked = commit = None
259 else:
260 self.clicked = commit = item.commit
262 has_single_selection = len(selected_items) == 1
263 has_single_selection_or_clicked = bool(has_single_selection or commit)
264 has_selection = bool(selected_items)
265 can_diff = bool(
266 commit
267 and has_single_selection
268 and selected_items
269 and commit is not selected_items[0].commit
271 has_branches = (
272 has_single_selection
273 and selected_item
274 and bool(selected_item.commit.branches)
275 ) or (self.clicked and bool(self.clicked.branches))
277 if can_diff:
278 self.selected = selected_items[0].commit
279 else:
280 self.selected = None
282 self.menu_actions['diff_this_selected'].setEnabled(can_diff)
283 self.menu_actions['diff_selected_this'].setEnabled(can_diff)
284 self.menu_actions['diff_commit'].setEnabled(has_single_selection_or_clicked)
285 self.menu_actions['diff_commit_all'].setEnabled(has_single_selection_or_clicked)
287 self.menu_actions['checkout_branch'].setEnabled(has_branches)
288 self.menu_actions['checkout_detached'].setEnabled(
289 has_single_selection_or_clicked
291 self.menu_actions['cherry_pick'].setEnabled(has_single_selection_or_clicked)
292 self.menu_actions['copy'].setEnabled(has_single_selection_or_clicked)
293 self.menu_actions['create_branch'].setEnabled(has_single_selection_or_clicked)
294 self.menu_actions['create_patch'].setEnabled(has_selection)
295 self.menu_actions['create_tag'].setEnabled(has_single_selection_or_clicked)
296 self.menu_actions['create_tarball'].setEnabled(has_single_selection_or_clicked)
297 self.menu_actions['rebase_to_commit'].setEnabled(
298 has_single_selection_or_clicked
300 self.menu_actions['reset_mixed'].setEnabled(has_single_selection_or_clicked)
301 self.menu_actions['reset_keep'].setEnabled(has_single_selection_or_clicked)
302 self.menu_actions['reset_merge'].setEnabled(has_single_selection_or_clicked)
303 self.menu_actions['reset_soft'].setEnabled(has_single_selection_or_clicked)
304 self.menu_actions['reset_hard'].setEnabled(has_single_selection_or_clicked)
305 self.menu_actions['restore_worktree'].setEnabled(
306 has_single_selection_or_clicked
308 self.menu_actions['revert'].setEnabled(has_single_selection_or_clicked)
309 self.menu_actions['save_blob'].setEnabled(has_single_selection_or_clicked)
311 def context_menu_event(self, event):
312 """Build a context menu and execute it"""
313 self.update_menu_actions(event)
314 menu = qtutils.create_menu(N_('Actions'), self)
315 menu.addAction(self.menu_actions['diff_this_selected'])
316 menu.addAction(self.menu_actions['diff_selected_this'])
317 menu.addAction(self.menu_actions['diff_commit'])
318 menu.addAction(self.menu_actions['diff_commit_all'])
319 menu.addSeparator()
320 menu.addAction(self.menu_actions['checkout_branch'])
321 menu.addAction(self.menu_actions['create_branch'])
322 menu.addAction(self.menu_actions['create_tag'])
323 menu.addAction(self.menu_actions['rebase_to_commit'])
324 menu.addSeparator()
325 menu.addAction(self.menu_actions['cherry_pick'])
326 menu.addAction(self.menu_actions['revert'])
327 menu.addAction(self.menu_actions['create_patch'])
328 menu.addAction(self.menu_actions['create_tarball'])
329 menu.addSeparator()
330 reset_menu = menu.addMenu(N_('Reset'))
331 reset_menu.addAction(self.menu_actions['reset_soft'])
332 reset_menu.addAction(self.menu_actions['reset_mixed'])
333 reset_menu.addAction(self.menu_actions['restore_worktree'])
334 reset_menu.addSeparator()
335 reset_menu.addAction(self.menu_actions['reset_keep'])
336 reset_menu.addAction(self.menu_actions['reset_merge'])
337 reset_menu.addAction(self.menu_actions['reset_hard'])
338 menu.addAction(self.menu_actions['checkout_detached'])
339 menu.addSeparator()
340 menu.addAction(self.menu_actions['save_blob'])
341 menu.addAction(self.menu_actions['copy'])
342 menu.exec_(self.mapToGlobal(event.pos()))
345 def set_icon(icon, action):
346 """ "Set the icon for an action and return the action"""
347 action.setIcon(icon)
348 return action
351 def viewer_actions(widget):
352 """Return commont actions across the tree and graph widgets"""
353 return {
354 'diff_this_selected': set_icon(
355 icons.compare(),
356 qtutils.add_action(
357 widget, N_('Diff this -> selected'), widget.proxy.diff_this_selected
360 'diff_selected_this': set_icon(
361 icons.compare(),
362 qtutils.add_action(
363 widget, N_('Diff selected -> this'), widget.proxy.diff_selected_this
366 'create_branch': set_icon(
367 icons.branch(),
368 qtutils.add_action(widget, N_('Create Branch'), widget.proxy.create_branch),
370 'create_patch': set_icon(
371 icons.save(),
372 qtutils.add_action(widget, N_('Create Patch'), widget.proxy.create_patch),
374 'create_tag': set_icon(
375 icons.tag(),
376 qtutils.add_action(widget, N_('Create Tag'), widget.proxy.create_tag),
378 'create_tarball': set_icon(
379 icons.file_zip(),
380 qtutils.add_action(
381 widget, N_('Save As Tarball/Zip...'), widget.proxy.create_tarball
384 'cherry_pick': set_icon(
385 icons.cherry_pick(),
386 qtutils.add_action(widget, N_('Cherry Pick'), widget.proxy.cherry_pick),
388 'revert': set_icon(
389 icons.undo(), qtutils.add_action(widget, N_('Revert'), widget.proxy.revert)
391 'diff_commit': set_icon(
392 icons.diff(),
393 qtutils.add_action(
394 widget, N_('Launch Diff Tool'), widget.proxy.show_diff, hotkeys.DIFF
397 'diff_commit_all': set_icon(
398 icons.diff(),
399 qtutils.add_action(
400 widget,
401 N_('Launch Directory Diff Tool'),
402 widget.proxy.show_dir_diff,
403 hotkeys.DIFF_SECONDARY,
406 'checkout_branch': set_icon(
407 icons.branch(),
408 qtutils.add_action(
409 widget, N_('Checkout Branch'), widget.proxy.checkout_branch
412 'checkout_detached': qtutils.add_action(
413 widget, N_('Checkout Detached HEAD'), widget.proxy.checkout_detached
415 'rebase_to_commit': set_icon(
416 icons.play(),
417 qtutils.add_action(
418 widget, N_('Rebase to this commit'), widget.proxy.rebase_to_commit
421 'reset_soft': set_icon(
422 icons.style_dialog_reset(),
423 qtutils.add_action(
424 widget, N_('Reset Branch (Soft)'), widget.proxy.reset_soft
427 'reset_mixed': set_icon(
428 icons.style_dialog_reset(),
429 qtutils.add_action(
430 widget, N_('Reset Branch and Stage (Mixed)'), widget.proxy.reset_mixed
433 'reset_keep': set_icon(
434 icons.style_dialog_reset(),
435 qtutils.add_action(
436 widget,
437 N_('Restore Worktree and Reset All (Keep Unstaged Edits)'),
438 widget.proxy.reset_keep,
441 'reset_merge': set_icon(
442 icons.style_dialog_reset(),
443 qtutils.add_action(
444 widget,
445 N_('Restore Worktree and Reset All (Merge)'),
446 widget.proxy.reset_merge,
449 'reset_hard': set_icon(
450 icons.style_dialog_reset(),
451 qtutils.add_action(
452 widget,
453 N_('Restore Worktree and Reset All (Hard)'),
454 widget.proxy.reset_hard,
457 'restore_worktree': set_icon(
458 icons.edit(),
459 qtutils.add_action(
460 widget, N_('Restore Worktree'), widget.proxy.restore_worktree
463 'save_blob': set_icon(
464 icons.save(),
465 qtutils.add_action(
466 widget, N_('Grab File...'), widget.proxy.save_blob_dialog
469 'copy': set_icon(
470 icons.copy(),
471 qtutils.add_action(
472 widget,
473 N_('Copy SHA-1'),
474 widget.proxy.copy_to_clipboard,
475 hotkeys.COPY_SHA1,
481 class GitDagLineEdit(completion.GitLogLineEdit):
482 """The text input field for specifying "git log" options"""
484 def __init__(self, context):
485 super().__init__(context)
486 self._action_filter_to_current_author = qtutils.add_action(
487 self, N_('Commits authored by me'), self._filter_to_current_author
489 self._action_pickaxe_search = qtutils.add_action(
490 self, N_('Pickaxe search for changes containing text'), self._pickaxe_search
492 self._action_grep_search = qtutils.add_action(
493 self,
494 N_('Search commit messages'),
495 self._grep_search,
497 self._action_no_merges = qtutils.add_action(
498 self, N_('Ignore merge commits'), self._no_merges
501 def contextMenuEvent(self, event):
502 """Adds custom actions to the default context menu"""
503 event_pos = event.pos()
504 menu = self.createStandardContextMenu()
505 menu.addSeparator()
506 actions = menu.actions()
507 first_action = actions[0]
508 menu.insertAction(first_action, self._action_pickaxe_search)
509 menu.insertAction(first_action, self._action_filter_to_current_author)
510 menu.insertAction(first_action, self._action_grep_search)
511 menu.insertAction(first_action, self._action_no_merges)
512 menu.insertSeparator(first_action)
513 menu.exec_(self.mapToGlobal(event_pos))
515 def insert(self, text):
516 """Insert text at the beginning of the current text"""
517 value = self.value()
518 if value:
519 text = f'{text} {value}'
520 self.setText(text)
521 self.close_popup()
523 def _filter_to_current_author(self):
524 """Filter to commits by the current author/user"""
525 _, email = self.context.cfg.get_author()
526 author_filter = '--author=' + email
527 self.insert(author_filter)
529 def _pickaxe_search(self):
530 """Pickaxe search for changes containing text"""
531 self.insert('-G"search"')
532 start = len('-G"')
533 length = len('search')
534 self.setSelection(start, length)
536 def _grep_search(self):
537 """Pickaxe search for changes containing text"""
538 self.insert('--grep="search"')
539 start = len('--grep="')
540 length = len('search')
541 self.setSelection(start, length)
543 def _no_merges(self):
544 """Ignore merge commits"""
545 self.insert('--no-merges')
548 class CommitTreeWidgetItem(QtWidgets.QTreeWidgetItem):
549 """Custom TreeWidgetItem used in to build the commit tree widget"""
551 def __init__(self, commit, parent=None):
552 QtWidgets.QTreeWidgetItem.__init__(self, parent)
553 self.commit = commit
554 self.setText(0, commit.summary)
555 self.setText(1, commit.author)
556 self.setText(2, commit.authdate)
559 # pylint: disable=too-many-ancestors
560 class CommitTreeWidget(standard.TreeWidget, ViewerMixin):
561 """Display commits using a flat treewidget in "list" mode"""
563 commits_selected = Signal(object)
564 diff_commits = Signal(object, object)
565 zoom_to_fit = Signal()
567 def __init__(self, context, parent):
568 standard.TreeWidget.__init__(self, parent)
569 ViewerMixin.__init__(self)
571 self.setSelectionMode(self.ExtendedSelection)
572 self.setHeaderLabels([N_('Summary'), N_('Author'), N_('Date, Time')])
574 self.context = context
575 self.oidmap = {}
576 self.menu_actions = None
577 self.selecting = False
578 self.commits = []
579 self._adjust_columns = False
581 self.action_up = qtutils.add_action(
582 self, N_('Go Up'), self.go_up, hotkeys.MOVE_UP
585 self.action_down = qtutils.add_action(
586 self, N_('Go Down'), self.go_down, hotkeys.MOVE_DOWN
589 self.zoom_to_fit_action = qtutils.add_action(
590 self, N_('Zoom to Fit'), self.zoom_to_fit.emit, hotkeys.FIT
593 # pylint: disable=no-member
594 self.itemSelectionChanged.connect(self.selection_changed)
596 def export_state(self):
597 """Export the widget's state"""
598 # The base class method is intentionally overridden because we only
599 # care about the details below for this subwidget.
600 state = {}
601 state['column_widths'] = self.column_widths()
602 return state
604 def apply_state(self, state):
605 """Apply the exported widget state"""
606 try:
607 column_widths = state['column_widths']
608 except (KeyError, ValueError):
609 column_widths = None
610 if column_widths:
611 self.set_column_widths(column_widths)
612 else:
613 # Defer showing the columns until we are shown, and our true width
614 # is known. Calling adjust_columns() here ends up with the wrong
615 # answer because we have not yet been parented to the layout.
616 # We set this flag that we process once during our initial
617 # showEvent().
618 self._adjust_columns = True
619 return True
621 # Qt overrides
622 def showEvent(self, event):
623 """Override QWidget::showEvent() to size columns when we are shown"""
624 if self._adjust_columns:
625 self._adjust_columns = False
626 width = self.width()
627 two_thirds = (width * 2) // 3
628 one_sixth = width // 6
630 self.setColumnWidth(0, two_thirds)
631 self.setColumnWidth(1, one_sixth)
632 self.setColumnWidth(2, one_sixth)
633 return standard.TreeWidget.showEvent(self, event)
635 # ViewerMixin
636 def go_up(self):
637 """Select the item above the current item"""
638 self.goto(self.itemAbove)
640 def go_down(self):
641 """Select the item below the current item"""
642 self.goto(self.itemBelow)
644 def goto(self, finder):
645 """Move the selection using a finder strategy"""
646 items = self.selected_items()
647 item = items[0] if items else None
648 if item is None:
649 return
650 found = finder(item)
651 if found:
652 self.select([found.commit.oid])
654 def selected_commit_range(self):
655 """Return a range of selected commits"""
656 selected_items = self.selected_items()
657 if not selected_items:
658 return None, None
659 return selected_items[-1].commit.oid, selected_items[0].commit.oid
661 def set_selecting(self, selecting):
662 """Record the "are we selecting?" status"""
663 self.selecting = selecting
665 def selection_changed(self):
666 """Respond to itemSelectionChanged notifications"""
667 items = self.selected_items()
668 if not items:
669 self.set_selecting(True)
670 self.commits_selected.emit([])
671 self.set_selecting(False)
672 return
673 self.set_selecting(True)
674 self.commits_selected.emit(sort_by_generation([i.commit for i in items]))
675 self.set_selecting(False)
677 def select_commits(self, commits):
678 """Select commits that were selected by the sibling tree/graph widget"""
679 if self.selecting:
680 return
681 with qtutils.BlockSignals(self):
682 self.select([commit.oid for commit in commits])
684 def select(self, oids):
685 """Mark items as selected"""
686 self.clearSelection()
687 if not oids:
688 return
689 for oid in oids:
690 try:
691 item = self.oidmap[oid]
692 except KeyError:
693 continue
694 self.scrollToItem(item)
695 item.setSelected(True)
697 def clear(self):
698 """Clear the tree"""
699 QtWidgets.QTreeWidget.clear(self)
700 self.oidmap.clear()
701 self.commits = []
703 def add_commits(self, commits):
704 """Add commits to the tree"""
705 self.commits.extend(commits)
706 items = []
707 for c in reversed(commits):
708 item = CommitTreeWidgetItem(c)
709 items.append(item)
710 self.oidmap[c.oid] = item
711 for tag in c.tags:
712 self.oidmap[tag] = item
713 self.insertTopLevelItems(0, items)
715 def create_patch(self):
716 """Export a patch from the selected items"""
717 items = self.selectedItems()
718 if not items:
719 return
720 context = self.context
721 oids = [item.commit.oid for item in reversed(items)]
722 all_oids = [c.oid for c in self.commits]
723 cmds.do(cmds.FormatPatch, context, oids, all_oids)
725 # Qt overrides
726 def contextMenuEvent(self, event):
727 """Create a custom context menu and execute it"""
728 self.context_menu_event(event)
730 def mousePressEvent(self, event):
731 """Intercept the right-click event to retain selection state"""
732 item = self.itemAt(event.pos())
733 if item is None:
734 self.clicked = None
735 else:
736 self.clicked = item.commit
737 if event.button() == Qt.RightButton:
738 event.accept()
739 return
740 QtWidgets.QTreeWidget.mousePressEvent(self, event)
743 class GitDAG(standard.MainWindow):
744 """The git-dag widget."""
746 commits_selected = Signal(object)
748 def __init__(self, context, params, parent=None):
749 super().__init__(parent)
751 self.setMinimumSize(420, 420)
753 # change when widgets are added/removed
754 self.widget_version = 2
755 self.context = context
756 self.params = params
757 self.model = context.model
759 self.commits = {}
760 self.commit_list = []
761 self.selection = []
762 self.old_refs = set()
763 self.old_oids = None
764 self.old_count = 0
765 self.force_refresh = False
767 self.thread = None
768 self.revtext = GitDagLineEdit(context)
769 self.maxresults = standard.SpinBox()
771 self.zoom_out = qtutils.create_action_button(
772 tooltip=N_('Zoom Out'), icon=icons.zoom_out()
775 self.zoom_in = qtutils.create_action_button(
776 tooltip=N_('Zoom In'), icon=icons.zoom_in()
779 self.zoom_to_fit = qtutils.create_action_button(
780 tooltip=N_('Zoom to Fit'), icon=icons.zoom_fit_best()
783 self.treewidget = CommitTreeWidget(context, self)
784 self.diffwidget = diff.DiffWidget(context, self, is_commit=True)
785 self.filewidget = filelist.FileWidget(context, self)
786 self.graphview = GraphView(context, self)
788 self.treewidget.commits_selected.connect(self.commits_selected)
789 self.graphview.commits_selected.connect(self.commits_selected)
791 self.commits_selected.connect(self.select_commits)
792 self.commits_selected.connect(self.diffwidget.commits_selected)
793 self.commits_selected.connect(self.filewidget.commits_selected)
794 self.commits_selected.connect(self.graphview.select_commits)
795 self.commits_selected.connect(self.treewidget.select_commits)
797 self.filewidget.files_selected.connect(self.diffwidget.files_selected)
798 self.filewidget.difftool_selected.connect(self.difftool_selected)
799 self.filewidget.histories_selected.connect(self.histories_selected)
801 self.proxy = FocusRedirectProxy(
802 self.treewidget, self.graphview, self.filewidget
805 self.viewer_actions = actions = viewer_actions(self)
806 self.treewidget.menu_actions = actions
807 self.graphview.menu_actions = actions
809 self.controls_layout = qtutils.hbox(
810 defs.no_margin, defs.spacing, self.revtext, self.maxresults
813 self.controls_widget = QtWidgets.QWidget()
814 self.controls_widget.setLayout(self.controls_layout)
816 self.log_dock = qtutils.create_dock('Log', N_('Log'), self, stretch=False)
817 self.log_dock.setWidget(self.treewidget)
818 log_dock_titlebar = self.log_dock.titleBarWidget()
819 log_dock_titlebar.add_corner_widget(self.controls_widget)
821 self.file_dock = qtutils.create_dock('Files', N_('Files'), self)
822 self.file_dock.setWidget(self.filewidget)
824 self.diff_panel = diff.DiffPanel(self.diffwidget, self.diffwidget.diff, self)
825 self.diff_options = diff.Options(self.diffwidget)
826 self.diffwidget.set_options(self.diff_options)
827 self.diff_options.hide_advanced_options()
828 self.diff_options.set_diff_type(main.Types.TEXT)
830 self.diff_dock = qtutils.create_dock('Diff', N_('Diff'), self)
831 self.diff_dock.setWidget(self.diff_panel)
833 diff_titlebar = self.diff_dock.titleBarWidget()
834 diff_titlebar.add_corner_widget(self.diff_options)
836 self.graph_controls_layout = qtutils.hbox(
837 defs.no_margin,
838 defs.button_spacing,
839 self.zoom_out,
840 self.zoom_in,
841 self.zoom_to_fit,
842 defs.spacing,
845 self.graph_controls_widget = QtWidgets.QWidget()
846 self.graph_controls_widget.setLayout(self.graph_controls_layout)
848 self.graphview_dock = qtutils.create_dock('Graph', N_('Graph'), self)
849 self.graphview_dock.setWidget(self.graphview)
850 graph_titlebar = self.graphview_dock.titleBarWidget()
851 graph_titlebar.add_corner_widget(self.graph_controls_widget)
853 self.lock_layout_action = qtutils.add_action_bool(
854 self, N_('Lock Layout'), self.set_lock_layout, False
857 self.refresh_action = qtutils.add_action(
858 self, N_('Refresh'), self.refresh, hotkeys.REFRESH
861 # Create the application menu
862 self.menubar = QtWidgets.QMenuBar(self)
863 self.setMenuBar(self.menubar)
865 # View Menu
866 self.view_menu = qtutils.add_menu(N_('View'), self.menubar)
867 self.view_menu.addAction(self.refresh_action)
868 self.view_menu.addAction(self.log_dock.toggleViewAction())
869 self.view_menu.addAction(self.graphview_dock.toggleViewAction())
870 self.view_menu.addAction(self.diff_dock.toggleViewAction())
871 self.view_menu.addAction(self.file_dock.toggleViewAction())
872 self.view_menu.addSeparator()
873 self.view_menu.addAction(self.lock_layout_action)
875 left = Qt.LeftDockWidgetArea
876 right = Qt.RightDockWidgetArea
877 self.addDockWidget(left, self.log_dock)
878 self.addDockWidget(left, self.diff_dock)
879 self.addDockWidget(right, self.graphview_dock)
880 self.addDockWidget(right, self.file_dock)
882 # Also re-loads dag.* from the saved state
883 self.init_state(context.settings, self.resize_to_desktop)
885 qtutils.connect_button(self.zoom_out, self.graphview.zoom_out)
886 qtutils.connect_button(self.zoom_in, self.graphview.zoom_in)
887 qtutils.connect_button(self.zoom_to_fit, self.graphview.zoom_to_fit)
889 self.treewidget.zoom_to_fit.connect(self.graphview.zoom_to_fit)
890 self.treewidget.diff_commits.connect(self.diff_commits)
891 self.graphview.diff_commits.connect(self.diff_commits)
892 self.filewidget.grab_file.connect(self.grab_file)
894 # pylint: disable=no-member
895 self.maxresults.editingFinished.connect(self.display)
897 self.revtext.textChanged.connect(self.text_changed)
898 self.revtext.activated.connect(self.display)
899 self.revtext.enter.connect(self.display)
900 self.revtext.down.connect(self.focus_tree)
902 # The model is updated in another thread so use
903 # signals/slots to bring control back to the main GUI thread
904 self.model.updated.connect(self.model_updated, type=Qt.QueuedConnection)
906 qtutils.add_action(self, 'FocusInput', self.focus_input, hotkeys.FOCUS_INPUT)
907 qtutils.add_action(self, 'FocusTree', self.focus_tree, hotkeys.FOCUS_TREE)
908 qtutils.add_action(self, 'FocusDiff', self.focus_diff, hotkeys.FOCUS_DIFF)
909 qtutils.add_close_action(self)
911 self.set_params(params)
913 def set_params(self, params):
914 context = self.context
915 self.params = params
917 # Update fields affected by model
918 self.revtext.setText(params.ref)
919 self.maxresults.setValue(params.count)
920 self.update_window_title()
922 if self.thread is not None:
923 self.thread.stop()
925 self.thread = ReaderThread(context, params, self)
927 thread = self.thread
928 thread.begin.connect(self.thread_begin, type=Qt.QueuedConnection)
929 thread.status.connect(self.thread_status, type=Qt.QueuedConnection)
930 thread.add.connect(self.add_commits, type=Qt.QueuedConnection)
931 thread.end.connect(self.thread_end, type=Qt.QueuedConnection)
933 def focus_input(self):
934 """Focus the revision input field"""
935 self.revtext.setFocus()
937 def focus_tree(self):
938 """Focus the revision tree list widget"""
939 self.treewidget.setFocus()
941 def focus_diff(self):
942 """Focus the diff widget"""
943 self.diffwidget.setFocus()
945 def text_changed(self, txt):
946 self.params.ref = txt
947 self.update_window_title()
949 def update_window_title(self):
950 project = self.model.project
951 if self.params.ref:
952 self.setWindowTitle(
953 N_('%(project)s: %(ref)s - DAG')
955 'project': project,
956 'ref': self.params.ref,
959 else:
960 self.setWindowTitle(project + N_(' - DAG'))
962 def export_state(self):
963 state = standard.MainWindow.export_state(self)
964 state['count'] = self.params.count
965 state['log'] = self.treewidget.export_state()
966 state['word_wrap'] = self.diffwidget.options.enable_word_wrapping.isChecked()
967 return state
969 def apply_state(self, state):
970 result = standard.MainWindow.apply_state(self, state)
971 try:
972 count = state['count']
973 if self.params.overridden('count'):
974 count = self.params.count
975 except (KeyError, TypeError, ValueError, AttributeError):
976 count = self.params.count
977 result = False
978 self.params.set_count(count)
979 self.lock_layout_action.setChecked(state.get('lock_layout', False))
980 self.diffwidget.set_word_wrapping(state.get('word_wrap', False), update=True)
982 try:
983 log_state = state['log']
984 except (KeyError, ValueError):
985 log_state = None
986 if log_state:
987 self.treewidget.apply_state(log_state)
989 return result
991 def model_updated(self):
992 self.display()
993 self.update_window_title()
995 def refresh(self):
996 """Unconditionally refresh the DAG"""
997 # self.force_refresh triggers an Unconditional redraw
998 self.force_refresh = True
999 cmds.do(cmds.Refresh, self.context)
1001 def display(self):
1002 """Update the view when the Git refs change"""
1003 ref = get(self.revtext)
1004 count = get(self.maxresults)
1005 context = self.context
1006 model = self.model
1007 # The DAG tries to avoid updating when the object IDs have not
1008 # changed. Without doing this the DAG constantly redraws itself
1009 # whenever inotify sends update events, which hurts usability.
1011 # To minimize redraws we leverage `git rev-parse`. The strategy is to
1012 # use `git rev-parse` on the input line, which converts each argument
1013 # into object IDs. From there it's a simple matter of detecting when
1014 # the object IDs changed.
1016 # In addition to object IDs, we also need to know when the set of
1017 # named references (branches, tags) changes so that an update is
1018 # triggered when new branches and tags are created.
1019 refs = set(model.local_branches + model.remote_branches + model.tags)
1020 argv = utils.shell_split(ref or 'HEAD')
1021 oids = gitcmds.parse_refs(context, argv)
1022 update = (
1023 self.force_refresh
1024 or count != self.old_count
1025 or oids != self.old_oids
1026 or refs != self.old_refs
1028 if update:
1029 self.thread.stop()
1030 self.params.set_ref(ref)
1031 self.params.set_count(count)
1032 self.thread.start()
1034 self.old_oids = oids
1035 self.old_count = count
1036 self.old_refs = refs
1037 self.force_refresh = False
1039 def select_commits(self, commits):
1040 self.selection = commits
1042 def clear(self):
1043 self.commits.clear()
1044 self.commit_list = []
1045 self.graphview.clear()
1046 self.treewidget.clear()
1048 def add_commits(self, commits):
1049 self.commit_list.extend(commits)
1050 # Keep track of commits
1051 for commit_obj in commits:
1052 self.commits[commit_obj.oid] = commit_obj
1053 for tag in commit_obj.tags:
1054 self.commits[tag] = commit_obj
1055 self.graphview.add_commits(commits)
1056 self.treewidget.add_commits(commits)
1058 def thread_begin(self):
1059 self.clear()
1061 def thread_end(self):
1062 self.restore_selection()
1064 def thread_status(self, successful):
1065 self.revtext.hint.set_error(not successful)
1067 def restore_selection(self):
1068 selection = self.selection
1069 try:
1070 commit_obj = self.commit_list[-1]
1071 except IndexError:
1072 # No commits, exist, early-out
1073 return
1075 new_commits = [self.commits.get(s.oid, None) for s in selection]
1076 new_commits = [c for c in new_commits if c is not None]
1077 if new_commits:
1078 # The old selection exists in the new state
1079 self.commits_selected.emit(sort_by_generation(new_commits))
1080 else:
1081 # The old selection is now empty. Select the top-most commit
1082 self.commits_selected.emit([commit_obj])
1084 self.graphview.set_initial_view()
1086 def diff_commits(self, left, right):
1087 paths = self.params.paths()
1088 if paths:
1089 difftool.difftool_launch(self.context, left=left, right=right, paths=paths)
1090 else:
1091 difftool.diff_commits(self.context, self, left, right)
1093 # Qt overrides
1094 def closeEvent(self, event):
1095 self.revtext.close_popup()
1096 self.thread.stop()
1097 standard.MainWindow.closeEvent(self, event)
1099 def histories_selected(self, histories):
1100 argv = [self.model.currentbranch, '--']
1101 argv.extend(histories)
1102 rev_text = core.list2cmdline(argv)
1103 self.revtext.setText(rev_text)
1104 self.display()
1106 def difftool_selected(self, files):
1107 bottom, top = self.treewidget.selected_commit_range()
1108 if not top:
1109 return
1110 difftool.difftool_launch(
1111 self.context, left=bottom, left_take_parent=True, right=top, paths=files
1114 def grab_file(self, filename):
1115 """Save the selected file from the filelist widget"""
1116 oid = self.treewidget.selected_oid()
1117 model = browse.BrowseModel(oid, filename=filename)
1118 browse.save_path(self.context, filename, model)
1121 class ReaderThread(QtCore.QThread):
1122 begin = Signal()
1123 add = Signal(object)
1124 end = Signal()
1125 status = Signal(object)
1127 def __init__(self, context, params, parent):
1128 QtCore.QThread.__init__(self, parent)
1129 self.context = context
1130 self.params = params
1131 self._abort = False
1132 self._stop = False
1133 self._mutex = QtCore.QMutex()
1134 self._condition = QtCore.QWaitCondition()
1136 def run(self):
1137 context = self.context
1138 repo = dag.RepoReader(context, self.params)
1139 repo.reset()
1140 self.begin.emit()
1141 commits = []
1142 for commit in repo.get():
1143 self._mutex.lock()
1144 if self._stop:
1145 self._condition.wait(self._mutex)
1146 self._mutex.unlock()
1147 if self._abort:
1148 repo.reset()
1149 return
1150 commits.append(commit)
1151 if len(commits) >= 512:
1152 self.add.emit(commits)
1153 commits = []
1155 self.status.emit(repo.returncode == 0)
1156 if commits:
1157 self.add.emit(commits)
1158 self.end.emit()
1160 def start(self):
1161 self._abort = False
1162 self._stop = False
1163 QtCore.QThread.start(self)
1165 def pause(self):
1166 self._mutex.lock()
1167 self._stop = True
1168 self._mutex.unlock()
1170 def resume(self):
1171 self._mutex.lock()
1172 self._stop = False
1173 self._mutex.unlock()
1174 self._condition.wakeOne()
1176 def stop(self):
1177 self._abort = True
1178 self.wait()
1181 class Cache:
1182 _label_font = None
1184 @classmethod
1185 def label_font(cls):
1186 font = cls._label_font
1187 if font is None:
1188 font = cls._label_font = QtWidgets.QApplication.font()
1189 font.setPointSize(6)
1190 return font
1193 class Edge(QtWidgets.QGraphicsItem):
1194 item_type = qtutils.standard_item_type_value(1)
1196 def __init__(self, source, dest):
1197 QtWidgets.QGraphicsItem.__init__(self)
1199 self.setAcceptedMouseButtons(Qt.NoButton)
1200 self.source = source
1201 self.dest = dest
1202 self.commit = source.commit
1203 self.setZValue(-2)
1205 self.recompute_bound()
1206 self.path = None
1207 self.path_valid = False
1209 # Choose a new color for new branch edges
1210 if self.source.x() < self.dest.x():
1211 color = EdgeColor.cycle()
1212 line = Qt.SolidLine
1213 elif self.source.x() != self.dest.x():
1214 color = EdgeColor.current()
1215 line = Qt.SolidLine
1216 else:
1217 color = EdgeColor.current()
1218 line = Qt.SolidLine
1220 self.pen = QtGui.QPen(color, 2.0, line, Qt.SquareCap, Qt.RoundJoin)
1222 def recompute_bound(self):
1223 dest_pt = Commit.item_bbox.center()
1225 self.source_pt = self.mapFromItem(self.source, dest_pt)
1226 self.dest_pt = self.mapFromItem(self.dest, dest_pt)
1227 self.line = QtCore.QLineF(self.source_pt, self.dest_pt)
1229 width = self.dest_pt.x() - self.source_pt.x()
1230 height = self.dest_pt.y() - self.source_pt.y()
1231 rect = QtCore.QRectF(self.source_pt, QtCore.QSizeF(width, height))
1232 self.bound = rect.normalized()
1234 def commits_were_invalidated(self):
1235 self.recompute_bound()
1236 self.prepareGeometryChange()
1237 # The path should not be recomputed immediately because just small part
1238 # of DAG is actually shown at same time. It will be recomputed on
1239 # demand in course of 'paint' method.
1240 self.path_valid = False
1241 # Hence, just queue redrawing.
1242 self.update()
1244 # Qt overrides
1245 def type(self):
1246 return self.item_type
1248 def boundingRect(self):
1249 return self.bound
1251 def recompute_path(self):
1252 QRectF = QtCore.QRectF
1253 QPointF = QtCore.QPointF
1255 arc_rect = 10
1256 connector_length = 5
1258 path = QtGui.QPainterPath()
1260 if self.source.x() == self.dest.x():
1261 path.moveTo(self.source.x(), self.source.y())
1262 path.lineTo(self.dest.x(), self.dest.y())
1263 else:
1264 # Define points starting from source
1265 point1 = QPointF(self.source.x(), self.source.y())
1266 point2 = QPointF(point1.x(), point1.y() - connector_length)
1267 point3 = QPointF(point2.x() + arc_rect, point2.y() - arc_rect)
1269 # Define points starting from dest
1270 point4 = QPointF(self.dest.x(), self.dest.y())
1271 point5 = QPointF(point4.x(), point3.y() - arc_rect)
1272 point6 = QPointF(point5.x() - arc_rect, point5.y() + arc_rect)
1274 start_angle_arc1 = 180
1275 span_angle_arc1 = 90
1276 start_angle_arc2 = 90
1277 span_angle_arc2 = -90
1279 # If the dest is at the left of the source, then we
1280 # need to reverse some values
1281 if self.source.x() > self.dest.x():
1282 point3 = QPointF(point2.x() - arc_rect, point3.y())
1283 point6 = QPointF(point5.x() + arc_rect, point6.y())
1285 span_angle_arc1 = 90
1287 path.moveTo(point1)
1288 path.lineTo(point2)
1289 path.arcTo(QRectF(point2, point3), start_angle_arc1, span_angle_arc1)
1290 path.lineTo(point6)
1291 path.arcTo(QRectF(point6, point5), start_angle_arc2, span_angle_arc2)
1292 path.lineTo(point4)
1294 self.path = path
1295 self.path_valid = True
1297 def paint(self, painter, _option, _widget):
1298 if not self.path_valid:
1299 self.recompute_path()
1300 painter.setPen(self.pen)
1301 painter.drawPath(self.path)
1304 class EdgeColor:
1305 """An edge color factory"""
1307 current_color_index = 0
1308 colors = [
1309 QtGui.QColor(Qt.red),
1310 QtGui.QColor(Qt.cyan),
1311 QtGui.QColor(Qt.magenta),
1312 QtGui.QColor(Qt.green),
1313 # Orange; Qt.yellow is too low-contrast
1314 qtutils.rgba(0xFF, 0x66, 0x00),
1317 @classmethod
1318 def update_colors(cls, theme):
1319 """Update the colors based on the color theme"""
1320 if theme.is_dark or theme.is_palette_dark:
1321 cls.colors.extend([
1322 QtGui.QColor(Qt.red).lighter(),
1323 QtGui.QColor(Qt.cyan).lighter(),
1324 QtGui.QColor(Qt.magenta).lighter(),
1325 QtGui.QColor(Qt.green).lighter(),
1326 QtGui.QColor(Qt.yellow).lighter(),
1328 else:
1329 cls.colors.extend([
1330 QtGui.QColor(Qt.blue),
1331 QtGui.QColor(Qt.darkRed),
1332 QtGui.QColor(Qt.darkCyan),
1333 QtGui.QColor(Qt.darkMagenta),
1334 QtGui.QColor(Qt.darkGreen),
1335 QtGui.QColor(Qt.darkYellow),
1336 QtGui.QColor(Qt.darkBlue),
1339 @classmethod
1340 def cycle(cls):
1341 cls.current_color_index += 1
1342 cls.current_color_index %= len(cls.colors)
1343 color = cls.colors[cls.current_color_index]
1344 color.setAlpha(128)
1345 return color
1347 @classmethod
1348 def current(cls):
1349 return cls.colors[cls.current_color_index]
1351 @classmethod
1352 def reset(cls):
1353 cls.current_color_index = 0
1356 class Commit(QtWidgets.QGraphicsItem):
1357 item_type = qtutils.standard_item_type_value(2)
1358 commit_radius = 12.0
1359 merge_radius = 18.0
1361 item_shape = QtGui.QPainterPath()
1362 item_shape.addRect(
1363 commit_radius / -2.0, commit_radius / -2.0, commit_radius, commit_radius
1365 item_bbox = item_shape.boundingRect()
1367 inner_rect = QtGui.QPainterPath()
1368 inner_rect.addRect(
1369 commit_radius / -2.0 + 2.0,
1370 commit_radius / -2.0 + 2.0,
1371 commit_radius - 4.0,
1372 commit_radius - 4.0,
1374 inner_rect = inner_rect.boundingRect()
1376 commit_color = QtGui.QColor(Qt.white)
1377 outline_color = commit_color.darker()
1378 merge_color = QtGui.QColor(Qt.lightGray)
1380 commit_selected_color = QtGui.QColor(Qt.green)
1381 selected_outline_color = commit_selected_color.darker()
1383 commit_pen = QtGui.QPen()
1384 commit_pen.setWidth(1)
1385 commit_pen.setColor(outline_color)
1387 def __init__(
1388 self,
1389 commit,
1390 selectable=QtWidgets.QGraphicsItem.ItemIsSelectable,
1391 cursor=Qt.PointingHandCursor,
1392 xpos=commit_radius / 2.0 + 1.0,
1393 cached_commit_color=commit_color,
1394 cached_merge_color=merge_color,
1396 QtWidgets.QGraphicsItem.__init__(self)
1398 self.commit = commit
1399 self.selected = False
1401 self.setZValue(0)
1402 self.setFlag(selectable)
1403 self.setCursor(cursor)
1404 self.setToolTip(commit.oid[:12] + ': ' + commit.summary)
1406 if commit.tags:
1407 self.label = label = Label(commit)
1408 label.setParentItem(self)
1409 label.setPos(xpos + 1, -self.commit_radius / 2.0)
1410 else:
1411 self.label = None
1413 if len(commit.parents) > 1:
1414 self.brush = cached_merge_color
1415 else:
1416 self.brush = cached_commit_color
1418 self.pressed = False
1419 self.dragged = False
1420 self.edges = {}
1422 def itemChange(self, change, value):
1423 if change == QtWidgets.QGraphicsItem.ItemSelectedHasChanged:
1424 # Cache the pen for use in paint()
1425 if value:
1426 self.brush = self.commit_selected_color
1427 color = self.selected_outline_color
1428 else:
1429 if len(self.commit.parents) > 1:
1430 self.brush = self.merge_color
1431 else:
1432 self.brush = self.commit_color
1433 color = self.outline_color
1434 commit_pen = QtGui.QPen()
1435 commit_pen.setWidth(1)
1436 commit_pen.setColor(color)
1437 self.commit_pen = commit_pen
1439 return QtWidgets.QGraphicsItem.itemChange(self, change, value)
1441 def type(self):
1442 return self.item_type
1444 def boundingRect(self):
1445 return self.item_bbox
1447 def shape(self):
1448 return self.item_shape
1450 def paint(self, painter, option, _widget):
1451 # Do not draw outside the exposed rect
1452 painter.setClipRect(option.exposedRect)
1454 # Draw ellipse
1455 painter.setPen(self.commit_pen)
1456 painter.setBrush(self.brush)
1457 painter.drawEllipse(self.inner_rect)
1459 def mousePressEvent(self, event):
1460 QtWidgets.QGraphicsItem.mousePressEvent(self, event)
1461 self.pressed = True
1462 self.selected = self.isSelected()
1464 def mouseMoveEvent(self, event):
1465 if self.pressed:
1466 self.dragged = True
1467 QtWidgets.QGraphicsItem.mouseMoveEvent(self, event)
1469 def mouseReleaseEvent(self, event):
1470 QtWidgets.QGraphicsItem.mouseReleaseEvent(self, event)
1471 if not self.dragged and self.selected and event.button() == Qt.LeftButton:
1472 return
1473 self.pressed = False
1474 self.dragged = False
1477 class Label(QtWidgets.QGraphicsItem):
1478 item_type = qtutils.graphics_item_type_value(3)
1480 head_color = QtGui.QColor(Qt.green)
1481 other_color = QtGui.QColor(Qt.white)
1482 remote_color = QtGui.QColor(Qt.yellow)
1484 head_pen = QtGui.QPen()
1485 head_pen.setColor(QtGui.QColor(Qt.black))
1486 head_pen.setWidth(1)
1488 text_pen = QtGui.QPen()
1489 text_pen.setColor(QtGui.QColor(Qt.black))
1490 text_pen.setWidth(1)
1492 border = 1
1493 item_spacing = 8
1494 text_x_offset = 3
1495 text_y_offset = 0
1497 def __init__(self, commit):
1498 QtWidgets.QGraphicsItem.__init__(self)
1499 self.setZValue(-1)
1500 self.commit = commit
1502 def type(self):
1503 return self.item_type
1505 def boundingRect(self, cache=Cache):
1506 QPainterPath = QtGui.QPainterPath
1507 QRectF = QtCore.QRectF
1509 width = 72
1510 height = 18
1511 current_width = 0
1512 spacing = self.item_spacing
1513 border_x = self.border + self.text_x_offset
1514 border_y = self.border + self.text_y_offset
1516 font = cache.label_font()
1517 item_shape = QPainterPath()
1519 base_rect = QRectF(0, 0, width, height)
1520 base_rect = base_rect.adjusted(-border_x, -border_y, border_x, border_y)
1521 item_shape.addRect(base_rect)
1523 for tag in self.commit.tags:
1524 text_shape = QPainterPath()
1525 text_shape.addText(current_width, 0, font, tag)
1526 text_rect = text_shape.boundingRect()
1527 box_rect = text_rect.adjusted(-border_x, -border_y, border_x, border_y)
1528 item_shape.addRect(box_rect)
1529 current_width = item_shape.boundingRect().width() + spacing
1531 return item_shape.boundingRect()
1533 def paint(self, painter, _option, _widget, cache=Cache):
1534 # Draw tags and branches
1535 font = cache.label_font()
1536 painter.setFont(font)
1538 current_width = 3
1539 border = self.border
1540 x_offset = self.text_x_offset
1541 y_offset = self.text_y_offset
1542 spacing = self.item_spacing
1543 QRectF = QtCore.QRectF
1545 HEAD = 'HEAD'
1546 remotes_prefix = 'remotes/'
1547 tags_prefix = 'tags/'
1548 heads_prefix = 'heads/'
1549 remotes_len = len(remotes_prefix)
1550 tags_len = len(tags_prefix)
1551 heads_len = len(heads_prefix)
1553 for tag in self.commit.tags:
1554 if tag == HEAD:
1555 painter.setPen(self.text_pen)
1556 painter.setBrush(self.remote_color)
1557 elif tag.startswith(remotes_prefix):
1558 tag = tag[remotes_len:]
1559 painter.setPen(self.text_pen)
1560 painter.setBrush(self.other_color)
1561 elif tag.startswith(tags_prefix):
1562 tag = tag[tags_len:]
1563 painter.setPen(self.text_pen)
1564 painter.setBrush(self.remote_color)
1565 elif tag.startswith(heads_prefix):
1566 tag = tag[heads_len:]
1567 painter.setPen(self.head_pen)
1568 painter.setBrush(self.head_color)
1569 else:
1570 painter.setPen(self.text_pen)
1571 painter.setBrush(self.other_color)
1573 text_rect = painter.boundingRect(
1574 QRectF(current_width, 0, 0, 0), Qt.TextSingleLine, tag
1576 box_rect = text_rect.adjusted(-x_offset, -y_offset, x_offset, y_offset)
1578 painter.drawRoundedRect(box_rect, border, border)
1579 painter.drawText(text_rect, Qt.TextSingleLine, tag)
1580 current_width += text_rect.width() + spacing
1583 # pylint: disable=too-many-ancestors
1584 class GraphView(QtWidgets.QGraphicsView, ViewerMixin):
1585 commits_selected = Signal(object)
1586 diff_commits = Signal(object, object)
1588 x_adjust = int(Commit.commit_radius * 4 / 3)
1589 y_adjust = int(Commit.commit_radius * 4 / 3)
1591 x_off = -18
1592 y_off = -20
1594 def __init__(self, context, parent):
1595 QtWidgets.QGraphicsView.__init__(self, parent)
1596 ViewerMixin.__init__(self)
1597 EdgeColor.update_colors(context.app.theme)
1599 theme = context.app.theme
1600 highlight = theme.selection_color()
1601 Commit.commit_selected_color = highlight
1602 Commit.selected_outline_color = highlight.darker()
1604 self.context = context
1605 self.columns = {}
1606 self.menu_actions = None
1607 self.commits = []
1608 self.items = {}
1609 self.mouse_start = [0, 0]
1610 self.saved_matrix = self.transform()
1611 self.max_column = 0
1612 self.min_column = 0
1613 self.frontier = {}
1614 self.tagged_cells = set()
1616 self.x_start = 24
1617 self.x_min = 24
1618 self.x_offsets = collections.defaultdict(lambda: self.x_min)
1620 self.is_panning = False
1621 self.pressed = False
1622 self.selecting = False
1623 self.last_mouse = [0, 0]
1624 self.zoom = 2
1625 self.setDragMode(self.RubberBandDrag)
1627 scene = QtWidgets.QGraphicsScene(self)
1628 scene.setItemIndexMethod(QtWidgets.QGraphicsScene.BspTreeIndex)
1629 self.setScene(scene)
1631 # pylint: disable=no-member
1632 scene.selectionChanged.connect(self.selection_changed)
1634 self.setRenderHint(QtGui.QPainter.Antialiasing)
1635 self.setViewportUpdateMode(self.SmartViewportUpdate)
1636 self.setCacheMode(QtWidgets.QGraphicsView.CacheBackground)
1637 self.setTransformationAnchor(QtWidgets.QGraphicsView.AnchorUnderMouse)
1638 self.setResizeAnchor(QtWidgets.QGraphicsView.NoAnchor)
1640 background_color = qtutils.css_color(context.app.theme.background_color_rgb())
1641 self.setBackgroundBrush(background_color)
1643 qtutils.add_action(
1644 self,
1645 N_('Zoom In'),
1646 self.zoom_in,
1647 hotkeys.ZOOM_IN,
1648 hotkeys.ZOOM_IN_SECONDARY,
1651 qtutils.add_action(self, N_('Zoom Out'), self.zoom_out, hotkeys.ZOOM_OUT)
1653 qtutils.add_action(self, N_('Zoom to Fit'), self.zoom_to_fit, hotkeys.FIT)
1655 qtutils.add_action(
1656 self, N_('Select Parent'), self._select_parent, hotkeys.MOVE_DOWN_TERTIARY
1659 qtutils.add_action(
1660 self,
1661 N_('Select Oldest Parent'),
1662 self._select_oldest_parent,
1663 hotkeys.MOVE_DOWN,
1666 qtutils.add_action(
1667 self, N_('Select Child'), self._select_child, hotkeys.MOVE_UP_TERTIARY
1670 qtutils.add_action(
1671 self, N_('Select Newest Child'), self._select_newest_child, hotkeys.MOVE_UP
1674 def clear(self):
1675 EdgeColor.reset()
1676 self.scene().clear()
1677 self.items.clear()
1678 self.x_offsets.clear()
1679 self.x_min = 24
1680 self.commits = []
1682 # ViewerMixin interface
1683 def selected_items(self):
1684 """Return the currently selected items"""
1685 return self.scene().selectedItems()
1687 def zoom_in(self):
1688 self.scale_view(1.5)
1690 def zoom_out(self):
1691 self.scale_view(1.0 / 1.5)
1693 def selection_changed(self):
1694 # Broadcast selection to other widgets
1695 selected_items = self.scene().selectedItems()
1696 commits = sort_by_generation([item.commit for item in selected_items])
1697 self.set_selecting(True)
1698 self.commits_selected.emit(commits)
1699 self.set_selecting(False)
1701 def select_commits(self, commits):
1702 if self.selecting:
1703 return
1704 with qtutils.BlockSignals(self.scene()):
1705 self.select([commit.oid for commit in commits])
1707 def select(self, oids):
1708 """Select the item for the oids"""
1709 self.scene().clearSelection()
1710 for oid in oids:
1711 try:
1712 item = self.items[oid]
1713 except KeyError:
1714 continue
1715 item.setSelected(True)
1716 item_rect = item.sceneTransform().mapRect(item.boundingRect())
1717 self.ensureVisible(item_rect)
1719 def _get_item_by_generation(self, commits, criteria_func):
1720 """Return the item for the commit matching criteria"""
1721 if not commits:
1722 return None
1723 generation = None
1724 for commit in commits:
1725 if generation is None or criteria_func(generation, commit.generation):
1726 oid = commit.oid
1727 generation = commit.generation
1728 try:
1729 return self.items[oid]
1730 except KeyError:
1731 return None
1733 def _oldest_item(self, commits):
1734 """Return the item for the commit with the oldest generation number"""
1735 return self._get_item_by_generation(commits, lambda a, b: a > b)
1737 def _newest_item(self, commits):
1738 """Return the item for the commit with the newest generation number"""
1739 return self._get_item_by_generation(commits, lambda a, b: a < b)
1741 def create_patch(self):
1742 items = self.selected_items()
1743 if not items:
1744 return
1745 context = self.context
1746 selected_commits = sort_by_generation([n.commit for n in items])
1747 oids = [c.oid for c in selected_commits]
1748 all_oids = [c.oid for c in sort_by_generation(self.commits)]
1749 cmds.do(cmds.FormatPatch, context, oids, all_oids)
1751 def _select_parent(self):
1752 """Select the parent with the newest generation number"""
1753 selected_item = self.selected_item()
1754 if selected_item is None:
1755 return
1756 parent_item = self._newest_item(selected_item.commit.parents)
1757 if parent_item is None:
1758 return
1759 selected_item.setSelected(False)
1760 parent_item.setSelected(True)
1761 self.ensureVisible(parent_item.mapRectToScene(parent_item.boundingRect()))
1763 def _select_oldest_parent(self):
1764 """Select the parent with the oldest generation number"""
1765 selected_item = self.selected_item()
1766 if selected_item is None:
1767 return
1768 parent_item = self._oldest_item(selected_item.commit.parents)
1769 if parent_item is None:
1770 return
1771 selected_item.setSelected(False)
1772 parent_item.setSelected(True)
1773 scene_rect = parent_item.mapRectToScene(parent_item.boundingRect())
1774 self.ensureVisible(scene_rect)
1776 def _select_child(self):
1777 """Select the child with the oldest generation number"""
1778 selected_item = self.selected_item()
1779 if selected_item is None:
1780 return
1781 child_item = self._oldest_item(selected_item.commit.children)
1782 if child_item is None:
1783 return
1784 selected_item.setSelected(False)
1785 child_item.setSelected(True)
1786 scene_rect = child_item.mapRectToScene(child_item.boundingRect())
1787 self.ensureVisible(scene_rect)
1789 def _select_newest_child(self):
1790 """Select the Nth child with the newest generation number (N > 1)"""
1791 selected_item = self.selected_item()
1792 if selected_item is None:
1793 return
1794 if len(selected_item.commit.children) > 1:
1795 children = selected_item.commit.children[1:]
1796 else:
1797 children = selected_item.commit.children
1798 child_item = self._newest_item(children)
1799 if child_item is None:
1800 return
1801 selected_item.setSelected(False)
1802 child_item.setSelected(True)
1803 scene_rect = child_item.mapRectToScene(child_item.boundingRect())
1804 self.ensureVisible(scene_rect)
1806 def set_initial_view(self):
1807 items = []
1808 selected = self.selected_items()
1809 if selected:
1810 items.extend(selected)
1812 if not selected and self.commits:
1813 commit = self.commits[-1]
1814 items.append(self.items[commit.oid])
1816 bounds = self.scene().itemsBoundingRect()
1817 bounds.adjust(-64, 0, 0, 0)
1818 self.setSceneRect(bounds)
1819 self.fit_view_to_items(items)
1821 def zoom_to_fit(self):
1822 """Fit selected items into the viewport"""
1823 items = self.selected_items()
1824 self.fit_view_to_items(items)
1826 def fit_view_to_items(self, items):
1827 if not items:
1828 rect = self.scene().itemsBoundingRect()
1829 else:
1830 x_min = y_min = maxsize
1831 x_max = y_max = -maxsize
1833 for item in items:
1834 pos = item.pos()
1835 x_val = pos.x()
1836 y_val = pos.y()
1837 x_min = min(x_min, x_val)
1838 x_max = max(x_max, x_val)
1839 y_min = min(y_min, y_val)
1840 y_max = max(y_max, y_val)
1842 rect = QtCore.QRectF(x_min, y_min, abs(x_max - x_min), abs(y_max - y_min))
1844 x_adjust = abs(GraphView.x_adjust)
1845 y_adjust = abs(GraphView.y_adjust)
1847 count = max(2.0, 10.0 - len(items) / 2.0)
1848 y_offset = int(y_adjust * count)
1849 x_offset = int(x_adjust * count)
1850 rect.setX(rect.x() - x_offset // 2)
1851 rect.setY(rect.y() - y_adjust // 2)
1852 rect.setHeight(rect.height() + y_offset)
1853 rect.setWidth(rect.width() + x_offset)
1855 self.fitInView(rect, Qt.KeepAspectRatio)
1856 self.scene().invalidate()
1858 def handle_event(self, event_handler, event, update=True):
1859 event_handler(self, event)
1860 if update:
1861 self.update()
1863 def set_selecting(self, selecting):
1864 self.selecting = selecting
1866 def pan(self, event):
1867 pos = event.pos()
1868 x_offset = pos.x() - self.mouse_start[0]
1869 y_offset = pos.y() - self.mouse_start[1]
1871 if x_offset == 0 and y_offset == 0:
1872 return
1874 rect = QtCore.QRect(0, 0, abs(x_offset), abs(y_offset))
1875 delta = self.mapToScene(rect).boundingRect()
1877 x_translate = delta.width()
1878 if x_offset < 0.0:
1879 x_translate = -x_translate
1881 y_translate = delta.height()
1882 if y_offset < 0.0:
1883 y_translate = -y_translate
1885 matrix = self.transform()
1886 matrix.reset()
1887 matrix *= self.saved_matrix
1888 matrix.translate(x_translate, y_translate)
1890 self.setTransformationAnchor(QtWidgets.QGraphicsView.NoAnchor)
1891 self.setTransform(matrix)
1893 def wheel_zoom(self, event):
1894 """Handle mouse wheel zooming."""
1895 delta = qtcompat.wheel_delta(event)
1896 zoom = math.pow(2.0, delta / 512.0)
1897 factor = (
1898 self.transform()
1899 .scale(zoom, zoom)
1900 .mapRect(QtCore.QRectF(0.0, 0.0, 1.0, 1.0))
1901 .width()
1903 if factor < 0.014 or factor > 42.0:
1904 return
1905 self.setTransformationAnchor(QtWidgets.QGraphicsView.AnchorUnderMouse)
1906 self.zoom = zoom
1907 self.scale(zoom, zoom)
1909 def wheel_pan(self, event):
1910 """Handle mouse wheel panning."""
1911 unit = QtCore.QRectF(0.0, 0.0, 1.0, 1.0)
1912 factor = 1.0 / self.transform().mapRect(unit).width()
1913 tx, ty = qtcompat.wheel_translation(event)
1915 matrix = self.transform().translate(tx * factor, ty * factor)
1916 self.setTransformationAnchor(QtWidgets.QGraphicsView.NoAnchor)
1917 self.setTransform(matrix)
1919 def scale_view(self, scale):
1920 factor = (
1921 self.transform()
1922 .scale(scale, scale)
1923 .mapRect(QtCore.QRectF(0, 0, 1, 1))
1924 .width()
1926 if factor < 0.07 or factor > 100.0:
1927 return
1928 self.zoom = scale
1930 adjust_scrollbars = False
1931 scrollbar = self.verticalScrollBar()
1932 scrollbar_offset = 1.0
1933 if scrollbar:
1934 value = get(scrollbar)
1935 minimum = scrollbar.minimum()
1936 maximum = scrollbar.maximum()
1937 scrollbar_range = maximum - minimum
1938 distance = value - minimum
1939 nonzero_range = scrollbar_range > 0.1
1940 if nonzero_range:
1941 scrollbar_offset = distance / scrollbar_range
1942 adjust_scrollbars = True
1944 self.setTransformationAnchor(QtWidgets.QGraphicsView.NoAnchor)
1945 self.scale(scale, scale)
1947 scrollbar = self.verticalScrollBar()
1948 if scrollbar and adjust_scrollbars:
1949 minimum = scrollbar.minimum()
1950 maximum = scrollbar.maximum()
1951 scrollbar_range = maximum - minimum
1952 value = minimum + int(float(scrollbar_range) * scrollbar_offset)
1953 scrollbar.setValue(value)
1955 def add_commits(self, commits):
1956 """Traverse commits and add them to the view."""
1957 self.commits.extend(commits)
1958 scene = self.scene()
1959 for commit in commits:
1960 item = Commit(commit)
1961 self.items[commit.oid] = item
1962 for ref in commit.tags:
1963 self.items[ref] = item
1964 scene.addItem(item)
1966 self.layout_commits()
1967 self.link(commits)
1969 def link(self, commits):
1970 """Create edges linking commits with their parents"""
1971 scene = self.scene()
1972 for commit in commits:
1973 try:
1974 commit_item = self.items[commit.oid]
1975 except KeyError:
1976 continue # The history is truncated.
1977 for parent in reversed(commit.parents):
1978 try:
1979 parent_item = self.items[parent.oid]
1980 except KeyError:
1981 continue # The history is truncated.
1982 try:
1983 edge = parent_item.edges[commit.oid]
1984 except KeyError:
1985 edge = Edge(parent_item, commit_item)
1986 else:
1987 continue
1988 parent_item.edges[commit.oid] = edge
1989 commit_item.edges[parent.oid] = edge
1990 scene.addItem(edge)
1992 def layout_commits(self):
1993 positions = self.position_nodes()
1995 # Each edge is accounted in two commits. Hence, accumulate invalid
1996 # edges to prevent double edge invalidation.
1997 invalid_edges = set()
1999 for oid, (x_val, y_val) in positions.items():
2000 item = self.items[oid]
2002 pos = item.pos()
2003 if pos != (x_val, y_val):
2004 item.setPos(x_val, y_val)
2006 for edge in item.edges.values():
2007 invalid_edges.add(edge)
2009 for edge in invalid_edges:
2010 edge.commits_were_invalidated()
2012 # Commit node layout technique
2014 # Nodes are aligned by a mesh. Columns and rows are distributed using
2015 # algorithms described below.
2017 # Row assignment algorithm
2019 # The algorithm aims consequent.
2020 # 1. A commit should be above all its parents.
2021 # 2. No commit should be at right side of a commit with a tag in same row.
2022 # This prevents overlapping of tag labels with commits and other labels.
2023 # 3. Commit density should be maximized.
2025 # The algorithm requires that all parents of a commit were assigned column.
2026 # Nodes must be traversed in generation ascend order. This guarantees that all
2027 # parents of a commit were assigned row. So, the algorithm may operate in
2028 # course of column assignment algorithm.
2030 # Row assignment uses frontier. A frontier is a dictionary that contains
2031 # minimum available row index for each column. It propagates during the
2032 # algorithm. Set of cells with tags is also maintained to meet second aim.
2034 # Initialization is performed by reset_rows method. Each new column should
2035 # be declared using declare_column method. Getting row for a cell is
2036 # implemented in alloc_cell method. Frontier must be propagated for any child
2037 # of fork commit which occupies different column. This meets first aim.
2039 # Column assignment algorithm
2041 # The algorithm traverses nodes in generation ascend order. This guarantees
2042 # that a node will be visited after all its parents.
2044 # The set of occupied columns are maintained during work. Initially it is
2045 # empty and no node occupied a column. Empty columns are allocated on demand.
2046 # Free index for column being allocated is searched in following way.
2047 # 1. Start from desired column and look towards graph center (0 column).
2048 # 2. Start from center and look in both directions simultaneously.
2049 # Desired column is defaulted to 0. Fork node should set desired column for
2050 # children equal to its one. This prevents branch from jumping too far from
2051 # its fork.
2053 # Initialization is performed by reset_columns method. Column allocation is
2054 # implemented in alloc_column method. Initialization and main loop are in
2055 # recompute_grid method. The method also embeds row assignment algorithm by
2056 # implementation.
2058 # Actions for each node are follow.
2059 # 1. If the node was not assigned a column then it is assigned empty one.
2060 # 2. Allocate row.
2061 # 3. Allocate columns for children.
2062 # If a child have a column assigned then it should no be overridden. One of
2063 # children is assigned same column as the node. If the node is a fork then the
2064 # child is chosen in generation descent order. This is a heuristic and it only
2065 # affects resulting appearance of the graph. Other children are assigned empty
2066 # columns in same order. It is the heuristic too.
2067 # 4. If no child occupies column of the node then leave it.
2068 # It is possible in consequent situations.
2069 # 4.1 The node is a leaf.
2070 # 4.2 The node is a fork and all its children are already assigned side
2071 # column. It is possible if all the children are merges.
2072 # 4.3 Single node child is a merge that is already assigned a column.
2073 # 5. Propagate frontier with respect to this node.
2074 # Each frontier entry corresponding to column occupied by any node's child
2075 # must be gather than node row index. This meets first aim of the row
2076 # assignment algorithm.
2077 # Note that frontier of child that occupies same row was propagated during
2078 # step 2. Hence, it must be propagated for children on side columns.
2080 def reset_columns(self):
2081 # Some children of displayed commits might not be accounted in
2082 # 'commits' list. It is common case during loading of big graph.
2083 # But, they are assigned a column that must be reseted. Hence, use
2084 # depth-first traversal to reset all columns assigned.
2085 for node in self.commits:
2086 if node.column is None:
2087 continue
2088 stack = [node]
2089 while stack:
2090 node = stack.pop()
2091 node.column = None
2092 for child in node.children:
2093 if child.column is not None:
2094 stack.append(child)
2096 self.columns = {}
2097 self.max_column = 0
2098 self.min_column = 0
2100 def reset_rows(self):
2101 self.frontier = {}
2102 self.tagged_cells = set()
2104 def declare_column(self, column):
2105 if self.frontier:
2106 # Align new column frontier by frontier of nearest column. If all
2107 # columns were left then select maximum frontier value.
2108 if not self.columns:
2109 self.frontier[column] = max(list(self.frontier.values()))
2110 return
2111 # This is heuristic that mostly affects roots. Note that the
2112 # frontier values for fork children will be overridden in course of
2113 # propagate_frontier.
2114 for offset in itertools.count(1):
2115 for value in (column + offset, column - offset):
2116 if value not in self.columns:
2117 # Column is not occupied.
2118 continue
2119 try:
2120 frontier = self.frontier[value]
2121 except KeyError:
2122 # Column 'c' was never allocated.
2123 continue
2125 frontier -= 1
2126 # The frontier of the column may be higher because of
2127 # tag overlapping prevention performed for previous head.
2128 try:
2129 if self.frontier[column] >= frontier:
2130 break
2131 except KeyError:
2132 pass
2134 self.frontier[column] = frontier
2135 break
2136 else:
2137 continue
2138 break
2139 else:
2140 # First commit must be assigned 0 row.
2141 self.frontier[column] = 0
2143 def alloc_column(self, column=0):
2144 columns = self.columns
2145 # First, look for free column by moving from desired column to graph
2146 # center (column 0).
2147 for c in range(column, 0, -1 if column > 0 else 1):
2148 if c not in columns:
2149 if c > self.max_column:
2150 self.max_column = c
2151 elif c < self.min_column:
2152 self.min_column = c
2153 break
2154 else:
2155 # If no free column was found between graph center and desired
2156 # column then look for free one by moving from center along both
2157 # directions simultaneously.
2158 for c in itertools.count(0):
2159 if c not in columns:
2160 if c > self.max_column:
2161 self.max_column = c
2162 break
2163 c = -c
2164 if c not in columns:
2165 if c < self.min_column:
2166 self.min_column = c
2167 break
2168 self.declare_column(c)
2169 columns[c] = 1
2170 return c
2172 def alloc_cell(self, column, tags):
2173 # Get empty cell from frontier.
2174 cell_row = self.frontier[column]
2176 if tags:
2177 # Prevent overlapping of tag with cells already allocated a row.
2178 if self.x_off > 0:
2179 can_overlap = list(range(column + 1, self.max_column + 1))
2180 else:
2181 can_overlap = list(range(column - 1, self.min_column - 1, -1))
2182 for value in can_overlap:
2183 frontier = self.frontier[value]
2184 if frontier > cell_row:
2185 cell_row = frontier
2187 # Avoid overlapping with tags of commits at cell_row.
2188 if self.x_off > 0:
2189 can_overlap = list(range(self.min_column, column))
2190 else:
2191 can_overlap = list(range(self.max_column, column, -1))
2192 for cell_row in itertools.count(cell_row):
2193 for value in can_overlap:
2194 if (value, cell_row) in self.tagged_cells:
2195 # Overlapping. Try next row.
2196 break
2197 else:
2198 # No overlapping was found.
2199 break
2200 # Note that all checks should be made for new cell_row value.
2202 if tags:
2203 self.tagged_cells.add((column, cell_row))
2205 # Propagate frontier.
2206 self.frontier[column] = cell_row + 1
2207 return cell_row
2209 def propagate_frontier(self, column, value):
2210 current = self.frontier[column]
2211 if current < value:
2212 self.frontier[column] = value
2214 def leave_column(self, column):
2215 count = self.columns[column]
2216 if count == 1:
2217 del self.columns[column]
2218 else:
2219 self.columns[column] = count - 1
2221 def recompute_grid(self):
2222 self.reset_columns()
2223 self.reset_rows()
2225 for node in sort_by_generation(list(self.commits)):
2226 if node.column is None:
2227 # Node is either root or its parent is not in items. This
2228 # happens when tree loading is in progress. Allocate new
2229 # columns for such nodes.
2230 node.column = self.alloc_column()
2232 node.row = self.alloc_cell(node.column, node.tags)
2234 # Allocate columns for children which are still without one. Also
2235 # propagate frontier for children.
2236 if node.is_fork():
2237 sorted_children = sorted(
2238 node.children, key=lambda c: c.generation, reverse=True
2240 citer = iter(sorted_children)
2241 for child in citer:
2242 if child.column is None:
2243 # Top most child occupies column of parent.
2244 child.column = node.column
2245 # Note that frontier is propagated in course of
2246 # alloc_cell.
2247 break
2248 self.propagate_frontier(child.column, node.row + 1)
2249 else:
2250 # No child occupies same column.
2251 self.leave_column(node.column)
2252 # Note that the loop below will pass no iteration.
2254 # Rest children are allocated new column.
2255 for child in citer:
2256 if child.column is None:
2257 child.column = self.alloc_column(node.column)
2258 self.propagate_frontier(child.column, node.row + 1)
2259 elif node.children:
2260 child = node.children[0]
2261 if child.column is None:
2262 child.column = node.column
2263 # Note that frontier is propagated in course of alloc_cell.
2264 elif child.column != node.column:
2265 # Child node have other parents and occupies column of one
2266 # of them.
2267 self.leave_column(node.column)
2268 # But frontier must be propagated with respect to this
2269 # parent.
2270 self.propagate_frontier(child.column, node.row + 1)
2271 else:
2272 # This is a leaf node.
2273 self.leave_column(node.column)
2275 def position_nodes(self):
2276 self.recompute_grid()
2278 x_start = self.x_start
2279 x_min = self.x_min
2280 x_off = self.x_off
2281 y_off = self.y_off
2283 positions = {}
2285 for node in self.commits:
2286 x_val = x_start + node.column * x_off
2287 y_val = y_off + node.row * y_off
2289 positions[node.oid] = (x_val, y_val)
2290 x_min = min(x_min, x_val)
2292 self.x_min = x_min
2294 return positions
2296 # Qt overrides
2297 def contextMenuEvent(self, event):
2298 self.context_menu_event(event)
2300 def mousePressEvent(self, event):
2301 if event.button() == Qt.MidButton:
2302 pos = event.pos()
2303 self.mouse_start = [pos.x(), pos.y()]
2304 self.saved_matrix = self.transform()
2305 self.is_panning = True
2306 return
2307 if event.button() == Qt.RightButton:
2308 event.ignore()
2309 return
2310 if event.button() == Qt.LeftButton:
2311 self.pressed = True
2312 self.handle_event(QtWidgets.QGraphicsView.mousePressEvent, event)
2314 def mouseMoveEvent(self, event):
2315 if self.is_panning:
2316 self.pan(event)
2317 return
2318 pos = self.mapToScene(event.pos())
2319 self.last_mouse[0] = pos.x()
2320 self.last_mouse[1] = pos.y()
2321 self.handle_event(QtWidgets.QGraphicsView.mouseMoveEvent, event, update=False)
2323 def mouseReleaseEvent(self, event):
2324 self.pressed = False
2325 if event.button() == Qt.MidButton:
2326 self.is_panning = False
2327 return
2328 self.handle_event(QtWidgets.QGraphicsView.mouseReleaseEvent, event)
2329 self.viewport().repaint()
2331 def wheelEvent(self, event):
2332 """Handle Qt mouse wheel events."""
2333 if event.modifiers() & Qt.ControlModifier:
2334 self.wheel_zoom(event)
2335 else:
2336 self.wheel_pan(event)
2338 def fitInView(self, rect, flags=Qt.IgnoreAspectRatio):
2339 """Override fitInView to remove unwanted margins
2341 https://bugreports.qt.io/browse/QTBUG-42331 - based on QT sources
2344 if self.scene() is None or rect.isNull():
2345 return
2346 unity = self.transform().mapRect(QtCore.QRectF(0, 0, 1, 1))
2347 self.scale(1.0 / unity.width(), 1.0 / unity.height())
2348 view_rect = self.viewport().rect()
2349 scene_rect = self.transform().mapRect(rect)
2350 xratio = view_rect.width() / scene_rect.width()
2351 yratio = view_rect.height() / scene_rect.height()
2352 if flags == Qt.KeepAspectRatio:
2353 xratio = yratio = min(xratio, yratio)
2354 elif flags == Qt.KeepAspectRatioByExpanding:
2355 xratio = yratio = max(xratio, yratio)
2356 self.scale(xratio, yratio)
2357 self.centerOn(rect.center())
2360 def sort_by_generation(commits):
2361 """Sort commits by their generation. Ensures consistent diffs and patch exports"""
2362 if len(commits) <= 1:
2363 return commits
2364 commits.sort(key=lambda x: x.generation)
2365 return commits
2368 # Glossary
2369 # ========
2370 # oid -- Git objects IDs (i.e. SHA-1 IDs)
2371 # ref -- Git references that resolve to a commit-ish (HEAD, branches, tags)