tree-wide: remove pylint cruft
[git-cola.git] / cola / widgets / dag.py
blobfca0a82a4300fd2e0c12f22d8cf9a2163a5a336b
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 commit 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 common 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 class CommitTreeWidget(standard.TreeWidget, ViewerMixin):
560 """Display commits using a flat treewidget in "list" mode"""
562 commits_selected = Signal(object)
563 diff_commits = Signal(object, object)
564 zoom_to_fit = Signal()
566 def __init__(self, context, parent):
567 standard.TreeWidget.__init__(self, parent)
568 ViewerMixin.__init__(self)
570 self.setSelectionMode(self.ExtendedSelection)
571 self.setHeaderLabels([N_('Summary'), N_('Author'), N_('Date, Time')])
573 self.context = context
574 self.oidmap = {}
575 self.menu_actions = None
576 self.selecting = False
577 self.commits = []
578 self._adjust_columns = False
580 self.action_up = qtutils.add_action(
581 self, N_('Go Up'), self.go_up, hotkeys.MOVE_UP
584 self.action_down = qtutils.add_action(
585 self, N_('Go Down'), self.go_down, hotkeys.MOVE_DOWN
588 self.zoom_to_fit_action = qtutils.add_action(
589 self, N_('Zoom to Fit'), self.zoom_to_fit.emit, hotkeys.FIT
592 self.itemSelectionChanged.connect(self.selection_changed)
594 def export_state(self):
595 """Export the widget's state"""
596 # The base class method is intentionally overridden because we only
597 # care about the details below for this sub-widget.
598 state = {}
599 state['column_widths'] = self.column_widths()
600 return state
602 def apply_state(self, state):
603 """Apply the exported widget state"""
604 try:
605 column_widths = state['column_widths']
606 except (KeyError, ValueError):
607 column_widths = None
608 if column_widths:
609 self.set_column_widths(column_widths)
610 else:
611 # Defer showing the columns until we are shown, and our true width
612 # is known. Calling adjust_columns() here ends up with the wrong
613 # answer because we have not yet been parented to the layout.
614 # We set this flag that we process once during our initial
615 # showEvent().
616 self._adjust_columns = True
617 return True
619 # Qt overrides
620 def showEvent(self, event):
621 """Override QWidget::showEvent() to size columns when we are shown"""
622 if self._adjust_columns:
623 self._adjust_columns = False
624 width = self.width()
625 two_thirds = (width * 2) // 3
626 one_sixth = width // 6
628 self.setColumnWidth(0, two_thirds)
629 self.setColumnWidth(1, one_sixth)
630 self.setColumnWidth(2, one_sixth)
631 return standard.TreeWidget.showEvent(self, event)
633 # ViewerMixin
634 def go_up(self):
635 """Select the item above the current item"""
636 self.goto(self.itemAbove)
638 def go_down(self):
639 """Select the item below the current item"""
640 self.goto(self.itemBelow)
642 def goto(self, finder):
643 """Move the selection using a finder strategy"""
644 items = self.selected_items()
645 item = items[0] if items else None
646 if item is None:
647 return
648 found = finder(item)
649 if found:
650 self.select([found.commit.oid])
652 def selected_commit_range(self):
653 """Return a range of selected commits"""
654 selected_items = self.selected_items()
655 if not selected_items:
656 return None, None
657 return selected_items[-1].commit.oid, selected_items[0].commit.oid
659 def set_selecting(self, selecting):
660 """Record the "are we selecting?" status"""
661 self.selecting = selecting
663 def selection_changed(self):
664 """Respond to itemSelectionChanged notifications"""
665 items = self.selected_items()
666 if not items:
667 self.set_selecting(True)
668 self.commits_selected.emit([])
669 self.set_selecting(False)
670 return
671 self.set_selecting(True)
672 self.commits_selected.emit(sort_by_generation([i.commit for i in items]))
673 self.set_selecting(False)
675 def select_commits(self, commits):
676 """Select commits that were selected by the sibling tree/graph widget"""
677 if self.selecting:
678 return
679 with qtutils.BlockSignals(self):
680 self.select([commit.oid for commit in commits])
682 def select(self, oids):
683 """Mark items as selected"""
684 self.clearSelection()
685 if not oids:
686 return
687 for oid in oids:
688 try:
689 item = self.oidmap[oid]
690 except KeyError:
691 continue
692 self.scrollToItem(item)
693 item.setSelected(True)
695 def clear(self):
696 """Clear the tree"""
697 QtWidgets.QTreeWidget.clear(self)
698 self.oidmap.clear()
699 self.commits = []
701 def add_commits(self, commits):
702 """Add commits to the tree"""
703 self.commits.extend(commits)
704 items = []
705 for c in reversed(commits):
706 item = CommitTreeWidgetItem(c)
707 items.append(item)
708 self.oidmap[c.oid] = item
709 for tag in c.tags:
710 self.oidmap[tag] = item
711 self.insertTopLevelItems(0, items)
713 def create_patch(self):
714 """Export a patch from the selected items"""
715 items = self.selectedItems()
716 if not items:
717 return
718 context = self.context
719 oids = [item.commit.oid for item in reversed(items)]
720 all_oids = [c.oid for c in self.commits]
721 cmds.do(cmds.FormatPatch, context, oids, all_oids)
723 # Qt overrides
724 def contextMenuEvent(self, event):
725 """Create a custom context menu and execute it"""
726 self.context_menu_event(event)
728 def mousePressEvent(self, event):
729 """Intercept the right-click event to retain selection state"""
730 item = self.itemAt(event.pos())
731 if item is None:
732 self.clicked = None
733 else:
734 self.clicked = item.commit
735 if event.button() == Qt.RightButton:
736 event.accept()
737 return
738 QtWidgets.QTreeWidget.mousePressEvent(self, event)
741 class GitDAG(standard.MainWindow):
742 """The git-dag widget."""
744 commits_selected = Signal(object)
746 def __init__(self, context, params, parent=None):
747 super().__init__(parent)
749 self.setMinimumSize(420, 420)
751 # change when widgets are added/removed
752 self.widget_version = 2
753 self.context = context
754 self.params = params
755 self.model = context.model
757 self.commits = {}
758 self.commit_list = []
759 self.selection = []
760 self.old_refs = set()
761 self.old_oids = None
762 self.old_count = 0
763 self.force_refresh = False
765 self.thread = None
766 self.revtext = GitDagLineEdit(context)
767 self.maxresults = standard.SpinBox()
769 self.zoom_out = qtutils.create_action_button(
770 tooltip=N_('Zoom Out'), icon=icons.zoom_out()
773 self.zoom_in = qtutils.create_action_button(
774 tooltip=N_('Zoom In'), icon=icons.zoom_in()
777 self.zoom_to_fit = qtutils.create_action_button(
778 tooltip=N_('Zoom to Fit'), icon=icons.zoom_fit_best()
781 self.treewidget = CommitTreeWidget(context, self)
782 self.diffwidget = diff.DiffWidget(context, self, is_commit=True)
783 self.filewidget = filelist.FileWidget(context, self)
784 self.graphview = GraphView(context, self)
786 self.treewidget.commits_selected.connect(self.commits_selected)
787 self.graphview.commits_selected.connect(self.commits_selected)
789 self.commits_selected.connect(self.select_commits)
790 self.commits_selected.connect(self.diffwidget.commits_selected)
791 self.commits_selected.connect(self.filewidget.commits_selected)
792 self.commits_selected.connect(self.graphview.select_commits)
793 self.commits_selected.connect(self.treewidget.select_commits)
795 self.filewidget.files_selected.connect(self.diffwidget.files_selected)
796 self.filewidget.difftool_selected.connect(self.difftool_selected)
797 self.filewidget.histories_selected.connect(self.histories_selected)
799 self.proxy = FocusRedirectProxy(
800 self.treewidget, self.graphview, self.filewidget
803 self.viewer_actions = actions = viewer_actions(self)
804 self.treewidget.menu_actions = actions
805 self.graphview.menu_actions = actions
807 self.controls_layout = qtutils.hbox(
808 defs.no_margin, defs.spacing, self.revtext, self.maxresults
811 self.controls_widget = QtWidgets.QWidget()
812 self.controls_widget.setLayout(self.controls_layout)
814 self.log_dock = qtutils.create_dock('Log', N_('Log'), self, stretch=False)
815 self.log_dock.setWidget(self.treewidget)
816 log_dock_titlebar = self.log_dock.titleBarWidget()
817 log_dock_titlebar.add_corner_widget(self.controls_widget)
819 self.file_dock = qtutils.create_dock('Files', N_('Files'), self)
820 self.file_dock.setWidget(self.filewidget)
822 self.diff_panel = diff.DiffPanel(self.diffwidget, self.diffwidget.diff, self)
823 self.diff_options = diff.Options(self.diffwidget)
824 self.diffwidget.set_options(self.diff_options)
825 self.diff_options.hide_advanced_options()
826 self.diff_options.set_diff_type(main.Types.TEXT)
828 self.diff_dock = qtutils.create_dock('Diff', N_('Diff'), self)
829 self.diff_dock.setWidget(self.diff_panel)
831 diff_titlebar = self.diff_dock.titleBarWidget()
832 diff_titlebar.add_corner_widget(self.diff_options)
834 self.graph_controls_layout = qtutils.hbox(
835 defs.no_margin,
836 defs.button_spacing,
837 self.zoom_out,
838 self.zoom_in,
839 self.zoom_to_fit,
840 defs.spacing,
843 self.graph_controls_widget = QtWidgets.QWidget()
844 self.graph_controls_widget.setLayout(self.graph_controls_layout)
846 self.graphview_dock = qtutils.create_dock('Graph', N_('Graph'), self)
847 self.graphview_dock.setWidget(self.graphview)
848 graph_titlebar = self.graphview_dock.titleBarWidget()
849 graph_titlebar.add_corner_widget(self.graph_controls_widget)
851 self.lock_layout_action = qtutils.add_action_bool(
852 self, N_('Lock Layout'), self.set_lock_layout, False
855 self.refresh_action = qtutils.add_action(
856 self, N_('Refresh'), self.refresh, hotkeys.REFRESH
859 # Create the application menu
860 self.menubar = QtWidgets.QMenuBar(self)
861 self.setMenuBar(self.menubar)
863 # View Menu
864 self.view_menu = qtutils.add_menu(N_('View'), self.menubar)
865 self.view_menu.addAction(self.refresh_action)
866 self.view_menu.addAction(self.log_dock.toggleViewAction())
867 self.view_menu.addAction(self.graphview_dock.toggleViewAction())
868 self.view_menu.addAction(self.diff_dock.toggleViewAction())
869 self.view_menu.addAction(self.file_dock.toggleViewAction())
870 self.view_menu.addSeparator()
871 self.view_menu.addAction(self.lock_layout_action)
873 left = Qt.LeftDockWidgetArea
874 right = Qt.RightDockWidgetArea
875 self.addDockWidget(left, self.log_dock)
876 self.addDockWidget(left, self.diff_dock)
877 self.addDockWidget(right, self.graphview_dock)
878 self.addDockWidget(right, self.file_dock)
880 # Also re-loads dag.* from the saved state
881 self.init_state(context.settings, self.resize_to_desktop)
883 qtutils.connect_button(self.zoom_out, self.graphview.zoom_out)
884 qtutils.connect_button(self.zoom_in, self.graphview.zoom_in)
885 qtutils.connect_button(self.zoom_to_fit, self.graphview.zoom_to_fit)
887 self.treewidget.zoom_to_fit.connect(self.graphview.zoom_to_fit)
888 self.treewidget.diff_commits.connect(self.diff_commits)
889 self.graphview.diff_commits.connect(self.diff_commits)
890 self.filewidget.grab_file.connect(self.grab_file)
891 self.maxresults.editingFinished.connect(self.display)
892 self.revtext.textChanged.connect(self.text_changed)
893 self.revtext.activated.connect(self.display)
894 self.revtext.enter.connect(self.display)
895 self.revtext.down.connect(self.focus_tree)
896 # The model is updated in another thread so use
897 # signals/slots to bring control back to the main GUI thread
898 self.model.updated.connect(self.model_updated, type=Qt.QueuedConnection)
900 qtutils.add_action(self, 'FocusInput', self.focus_input, hotkeys.FOCUS_INPUT)
901 qtutils.add_action(self, 'FocusTree', self.focus_tree, hotkeys.FOCUS_TREE)
902 qtutils.add_action(self, 'FocusDiff', self.focus_diff, hotkeys.FOCUS_DIFF)
903 qtutils.add_close_action(self)
905 self.set_params(params)
907 def set_params(self, params):
908 context = self.context
909 self.params = params
911 # Update fields affected by model
912 self.revtext.setText(params.ref)
913 self.maxresults.setValue(params.count)
914 self.update_window_title()
916 if self.thread is not None:
917 self.thread.stop()
919 self.thread = ReaderThread(context, params, self)
921 thread = self.thread
922 thread.begin.connect(self.thread_begin, type=Qt.QueuedConnection)
923 thread.status.connect(self.thread_status, type=Qt.QueuedConnection)
924 thread.add.connect(self.add_commits, type=Qt.QueuedConnection)
925 thread.end.connect(self.thread_end, type=Qt.QueuedConnection)
927 def focus_input(self):
928 """Focus the revision input field"""
929 self.revtext.setFocus()
931 def focus_tree(self):
932 """Focus the revision tree list widget"""
933 self.treewidget.setFocus()
935 def focus_diff(self):
936 """Focus the diff widget"""
937 self.diffwidget.setFocus()
939 def text_changed(self, txt):
940 self.params.ref = txt
941 self.update_window_title()
943 def update_window_title(self):
944 project = self.model.project
945 if self.params.ref:
946 self.setWindowTitle(
947 N_('%(project)s: %(ref)s - DAG')
949 'project': project,
950 'ref': self.params.ref,
953 else:
954 self.setWindowTitle(project + N_(' - DAG'))
956 def export_state(self):
957 state = standard.MainWindow.export_state(self)
958 state['count'] = self.params.count
959 state['log'] = self.treewidget.export_state()
960 state['word_wrap'] = self.diffwidget.options.enable_word_wrapping.isChecked()
961 return state
963 def apply_state(self, state):
964 result = standard.MainWindow.apply_state(self, state)
965 try:
966 count = state['count']
967 if self.params.overridden('count'):
968 count = self.params.count
969 except (KeyError, TypeError, ValueError, AttributeError):
970 count = self.params.count
971 result = False
972 self.params.set_count(count)
973 self.lock_layout_action.setChecked(state.get('lock_layout', False))
974 self.diffwidget.set_word_wrapping(state.get('word_wrap', False), update=True)
976 try:
977 log_state = state['log']
978 except (KeyError, ValueError):
979 log_state = None
980 if log_state:
981 self.treewidget.apply_state(log_state)
983 return result
985 def model_updated(self):
986 self.display()
987 self.update_window_title()
989 def refresh(self):
990 """Unconditionally refresh the DAG"""
991 # self.force_refresh triggers an Unconditional redraw
992 self.force_refresh = True
993 cmds.do(cmds.Refresh, self.context)
995 def display(self):
996 """Update the view when the Git refs change"""
997 ref = get(self.revtext)
998 count = get(self.maxresults)
999 context = self.context
1000 model = self.model
1001 # The DAG tries to avoid updating when the object IDs have not
1002 # changed. Without doing this the DAG constantly redraws itself
1003 # whenever inotify sends update events, which hurts usability.
1005 # To minimize redraws we leverage `git rev-parse`. The strategy is to
1006 # use `git rev-parse` on the input line, which converts each argument
1007 # into object IDs. From there it's a simple matter of detecting when
1008 # the object IDs changed.
1010 # In addition to object IDs, we also need to know when the set of
1011 # named references (branches, tags) changes so that an update is
1012 # triggered when new branches and tags are created.
1013 refs = set(model.local_branches + model.remote_branches + model.tags)
1014 argv = utils.shell_split(ref or 'HEAD')
1015 oids = gitcmds.parse_refs(context, argv)
1016 update = (
1017 self.force_refresh
1018 or count != self.old_count
1019 or oids != self.old_oids
1020 or refs != self.old_refs
1022 if update:
1023 self.thread.stop()
1024 self.params.set_ref(ref)
1025 self.params.set_count(count)
1026 self.thread.start()
1028 self.old_oids = oids
1029 self.old_count = count
1030 self.old_refs = refs
1031 self.force_refresh = False
1033 def select_commits(self, commits):
1034 self.selection = commits
1036 def clear(self):
1037 self.commits.clear()
1038 self.commit_list = []
1039 self.graphview.clear()
1040 self.treewidget.clear()
1042 def add_commits(self, commits):
1043 self.commit_list.extend(commits)
1044 # Keep track of commits
1045 for commit_obj in commits:
1046 self.commits[commit_obj.oid] = commit_obj
1047 for tag in commit_obj.tags:
1048 self.commits[tag] = commit_obj
1049 self.graphview.add_commits(commits)
1050 self.treewidget.add_commits(commits)
1052 def thread_begin(self):
1053 self.clear()
1055 def thread_end(self):
1056 self.restore_selection()
1058 def thread_status(self, successful):
1059 self.revtext.hint.set_error(not successful)
1061 def restore_selection(self):
1062 selection = self.selection
1063 try:
1064 commit_obj = self.commit_list[-1]
1065 except IndexError:
1066 # No commits, exist, early-out
1067 return
1069 new_commits = [self.commits.get(s.oid, None) for s in selection]
1070 new_commits = [c for c in new_commits if c is not None]
1071 if new_commits:
1072 # The old selection exists in the new state
1073 self.commits_selected.emit(sort_by_generation(new_commits))
1074 else:
1075 # The old selection is now empty. Select the top-most commit
1076 self.commits_selected.emit([commit_obj])
1078 self.graphview.set_initial_view()
1080 def diff_commits(self, left, right):
1081 paths = self.params.paths()
1082 if paths:
1083 difftool.difftool_launch(self.context, left=left, right=right, paths=paths)
1084 else:
1085 difftool.diff_commits(self.context, self, left, right)
1087 # Qt overrides
1088 def closeEvent(self, event):
1089 self.revtext.close_popup()
1090 self.thread.stop()
1091 standard.MainWindow.closeEvent(self, event)
1093 def histories_selected(self, histories):
1094 argv = [self.model.currentbranch, '--']
1095 argv.extend(histories)
1096 rev_text = core.list2cmdline(argv)
1097 self.revtext.setText(rev_text)
1098 self.display()
1100 def difftool_selected(self, files):
1101 bottom, top = self.treewidget.selected_commit_range()
1102 if not top:
1103 return
1104 difftool.difftool_launch(
1105 self.context, left=bottom, left_take_parent=True, right=top, paths=files
1108 def grab_file(self, filename):
1109 """Save the selected file from the file list widget"""
1110 oid = self.treewidget.selected_oid()
1111 model = browse.BrowseModel(oid, filename=filename)
1112 browse.save_path(self.context, filename, model)
1115 class ReaderThread(QtCore.QThread):
1116 begin = Signal()
1117 add = Signal(object)
1118 end = Signal()
1119 status = Signal(object)
1121 def __init__(self, context, params, parent):
1122 QtCore.QThread.__init__(self, parent)
1123 self.context = context
1124 self.params = params
1125 self._abort = False
1126 self._stop = False
1127 self._mutex = QtCore.QMutex()
1128 self._condition = QtCore.QWaitCondition()
1130 def run(self):
1131 context = self.context
1132 repo = dag.RepoReader(context, self.params)
1133 repo.reset()
1134 self.begin.emit()
1135 commits = []
1136 for commit in repo.get():
1137 self._mutex.lock()
1138 if self._stop:
1139 self._condition.wait(self._mutex)
1140 self._mutex.unlock()
1141 if self._abort:
1142 repo.reset()
1143 return
1144 commits.append(commit)
1145 if len(commits) >= 512:
1146 self.add.emit(commits)
1147 commits = []
1149 self.status.emit(repo.returncode == 0)
1150 if commits:
1151 self.add.emit(commits)
1152 self.end.emit()
1154 def start(self):
1155 self._abort = False
1156 self._stop = False
1157 QtCore.QThread.start(self)
1159 def pause(self):
1160 self._mutex.lock()
1161 self._stop = True
1162 self._mutex.unlock()
1164 def resume(self):
1165 self._mutex.lock()
1166 self._stop = False
1167 self._mutex.unlock()
1168 self._condition.wakeOne()
1170 def stop(self):
1171 self._abort = True
1172 self.wait()
1175 class Cache:
1176 _label_font = None
1178 @classmethod
1179 def label_font(cls):
1180 font = cls._label_font
1181 if font is None:
1182 font = cls._label_font = QtWidgets.QApplication.font()
1183 font.setPointSize(6)
1184 return font
1187 class Edge(QtWidgets.QGraphicsItem):
1188 item_type = qtutils.standard_item_type_value(1)
1190 def __init__(self, source, dest):
1191 QtWidgets.QGraphicsItem.__init__(self)
1193 self.setAcceptedMouseButtons(Qt.NoButton)
1194 self.source = source
1195 self.dest = dest
1196 self.commit = source.commit
1197 self.setZValue(-2)
1199 self.recompute_bound()
1200 self.path = None
1201 self.path_valid = False
1203 # Choose a new color for new branch edges
1204 if self.source.x() < self.dest.x():
1205 color = EdgeColor.cycle()
1206 line = Qt.SolidLine
1207 elif self.source.x() != self.dest.x():
1208 color = EdgeColor.current()
1209 line = Qt.SolidLine
1210 else:
1211 color = EdgeColor.current()
1212 line = Qt.SolidLine
1214 self.pen = QtGui.QPen(color, 2.0, line, Qt.SquareCap, Qt.RoundJoin)
1216 def recompute_bound(self):
1217 dest_pt = Commit.item_bbox.center()
1219 self.source_pt = self.mapFromItem(self.source, dest_pt)
1220 self.dest_pt = self.mapFromItem(self.dest, dest_pt)
1221 self.line = QtCore.QLineF(self.source_pt, self.dest_pt)
1223 width = self.dest_pt.x() - self.source_pt.x()
1224 height = self.dest_pt.y() - self.source_pt.y()
1225 rect = QtCore.QRectF(self.source_pt, QtCore.QSizeF(width, height))
1226 self.bound = rect.normalized()
1228 def commits_were_invalidated(self):
1229 self.recompute_bound()
1230 self.prepareGeometryChange()
1231 # The path should not be recomputed immediately because just small part
1232 # of DAG is actually shown at same time. It will be recomputed on
1233 # demand in course of 'paint' method.
1234 self.path_valid = False
1235 # Hence, just queue redrawing.
1236 self.update()
1238 # Qt overrides
1239 def type(self):
1240 return self.item_type
1242 def boundingRect(self):
1243 return self.bound
1245 def recompute_path(self):
1246 QRectF = QtCore.QRectF
1247 QPointF = QtCore.QPointF
1249 arc_rect = 10
1250 connector_length = 5
1252 path = QtGui.QPainterPath()
1254 if self.source.x() == self.dest.x():
1255 path.moveTo(self.source.x(), self.source.y())
1256 path.lineTo(self.dest.x(), self.dest.y())
1257 else:
1258 # Define points starting from the source.
1259 point1 = QPointF(self.source.x(), self.source.y())
1260 point2 = QPointF(point1.x(), point1.y() - connector_length)
1261 point3 = QPointF(point2.x() + arc_rect, point2.y() - arc_rect)
1263 # Define points starting from the destination.
1264 point4 = QPointF(self.dest.x(), self.dest.y())
1265 point5 = QPointF(point4.x(), point3.y() - arc_rect)
1266 point6 = QPointF(point5.x() - arc_rect, point5.y() + arc_rect)
1268 start_angle_arc1 = 180
1269 span_angle_arc1 = 90
1270 start_angle_arc2 = 90
1271 span_angle_arc2 = -90
1273 # If the destination is at the left of the source, then we need to
1274 # reverse some values.
1275 if self.source.x() > self.dest.x():
1276 point3 = QPointF(point2.x() - arc_rect, point3.y())
1277 point6 = QPointF(point5.x() + arc_rect, point6.y())
1279 span_angle_arc1 = 90
1281 path.moveTo(point1)
1282 path.lineTo(point2)
1283 path.arcTo(QRectF(point2, point3), start_angle_arc1, span_angle_arc1)
1284 path.lineTo(point6)
1285 path.arcTo(QRectF(point6, point5), start_angle_arc2, span_angle_arc2)
1286 path.lineTo(point4)
1288 self.path = path
1289 self.path_valid = True
1291 def paint(self, painter, _option, _widget):
1292 if not self.path_valid:
1293 self.recompute_path()
1294 painter.setPen(self.pen)
1295 painter.drawPath(self.path)
1298 class EdgeColor:
1299 """An edge color factory"""
1301 current_color_index = 0
1302 colors = [
1303 QtGui.QColor(Qt.red),
1304 QtGui.QColor(Qt.cyan),
1305 QtGui.QColor(Qt.magenta),
1306 QtGui.QColor(Qt.green),
1307 # Orange; Qt.yellow is too low-contrast
1308 qtutils.rgba(0xFF, 0x66, 0x00),
1311 @classmethod
1312 def update_colors(cls, theme):
1313 """Update the colors based on the color theme"""
1314 if theme.is_dark or theme.is_palette_dark:
1315 cls.colors.extend([
1316 QtGui.QColor(Qt.red).lighter(),
1317 QtGui.QColor(Qt.cyan).lighter(),
1318 QtGui.QColor(Qt.magenta).lighter(),
1319 QtGui.QColor(Qt.green).lighter(),
1320 QtGui.QColor(Qt.yellow).lighter(),
1322 else:
1323 cls.colors.extend([
1324 QtGui.QColor(Qt.blue),
1325 QtGui.QColor(Qt.darkRed),
1326 QtGui.QColor(Qt.darkCyan),
1327 QtGui.QColor(Qt.darkMagenta),
1328 QtGui.QColor(Qt.darkGreen),
1329 QtGui.QColor(Qt.darkYellow),
1330 QtGui.QColor(Qt.darkBlue),
1333 @classmethod
1334 def cycle(cls):
1335 cls.current_color_index += 1
1336 cls.current_color_index %= len(cls.colors)
1337 color = cls.colors[cls.current_color_index]
1338 color.setAlpha(128)
1339 return color
1341 @classmethod
1342 def current(cls):
1343 return cls.colors[cls.current_color_index]
1345 @classmethod
1346 def reset(cls):
1347 cls.current_color_index = 0
1350 class Commit(QtWidgets.QGraphicsItem):
1351 item_type = qtutils.standard_item_type_value(2)
1352 commit_radius = 12.0
1353 merge_radius = 18.0
1355 item_shape = QtGui.QPainterPath()
1356 item_shape.addRect(
1357 commit_radius / -2.0, commit_radius / -2.0, commit_radius, commit_radius
1359 item_bbox = item_shape.boundingRect()
1361 inner_rect = QtGui.QPainterPath()
1362 inner_rect.addRect(
1363 commit_radius / -2.0 + 2.0,
1364 commit_radius / -2.0 + 2.0,
1365 commit_radius - 4.0,
1366 commit_radius - 4.0,
1368 inner_rect = inner_rect.boundingRect()
1370 commit_color = QtGui.QColor(Qt.white)
1371 outline_color = commit_color.darker()
1372 merge_color = QtGui.QColor(Qt.lightGray)
1374 commit_selected_color = QtGui.QColor(Qt.green)
1375 selected_outline_color = commit_selected_color.darker()
1377 commit_pen = QtGui.QPen()
1378 commit_pen.setWidth(1)
1379 commit_pen.setColor(outline_color)
1381 def __init__(
1382 self,
1383 commit,
1384 selectable=QtWidgets.QGraphicsItem.ItemIsSelectable,
1385 cursor=Qt.PointingHandCursor,
1386 xpos=commit_radius / 2.0 + 1.0,
1387 cached_commit_color=commit_color,
1388 cached_merge_color=merge_color,
1390 QtWidgets.QGraphicsItem.__init__(self)
1392 self.commit = commit
1393 self.selected = False
1395 self.setZValue(0)
1396 self.setFlag(selectable)
1397 self.setCursor(cursor)
1398 self.setToolTip(commit.oid[:12] + ': ' + commit.summary)
1400 if commit.tags:
1401 self.label = label = Label(commit)
1402 label.setParentItem(self)
1403 label.setPos(xpos + 1, -self.commit_radius / 2.0)
1404 else:
1405 self.label = None
1407 if len(commit.parents) > 1:
1408 self.brush = cached_merge_color
1409 else:
1410 self.brush = cached_commit_color
1412 self.pressed = False
1413 self.dragged = False
1414 self.edges = {}
1416 def itemChange(self, change, value):
1417 if change == QtWidgets.QGraphicsItem.ItemSelectedHasChanged:
1418 # Cache the pen for use in paint()
1419 if value:
1420 self.brush = self.commit_selected_color
1421 color = self.selected_outline_color
1422 else:
1423 if len(self.commit.parents) > 1:
1424 self.brush = self.merge_color
1425 else:
1426 self.brush = self.commit_color
1427 color = self.outline_color
1428 commit_pen = QtGui.QPen()
1429 commit_pen.setWidth(1)
1430 commit_pen.setColor(color)
1431 self.commit_pen = commit_pen
1433 return QtWidgets.QGraphicsItem.itemChange(self, change, value)
1435 def type(self):
1436 return self.item_type
1438 def boundingRect(self):
1439 return self.item_bbox
1441 def shape(self):
1442 return self.item_shape
1444 def paint(self, painter, option, _widget):
1445 # Do not draw outside the exposed rectangle.
1446 painter.setClipRect(option.exposedRect)
1448 # Draw ellipse
1449 painter.setPen(self.commit_pen)
1450 painter.setBrush(self.brush)
1451 painter.drawEllipse(self.inner_rect)
1453 def mousePressEvent(self, event):
1454 QtWidgets.QGraphicsItem.mousePressEvent(self, event)
1455 self.pressed = True
1456 self.selected = self.isSelected()
1458 def mouseMoveEvent(self, event):
1459 if self.pressed:
1460 self.dragged = True
1461 QtWidgets.QGraphicsItem.mouseMoveEvent(self, event)
1463 def mouseReleaseEvent(self, event):
1464 QtWidgets.QGraphicsItem.mouseReleaseEvent(self, event)
1465 if not self.dragged and self.selected and event.button() == Qt.LeftButton:
1466 return
1467 self.pressed = False
1468 self.dragged = False
1471 class Label(QtWidgets.QGraphicsItem):
1472 item_type = qtutils.graphics_item_type_value(3)
1474 head_color = QtGui.QColor(Qt.green)
1475 other_color = QtGui.QColor(Qt.white)
1476 remote_color = QtGui.QColor(Qt.yellow)
1478 head_pen = QtGui.QPen()
1479 head_pen.setColor(QtGui.QColor(Qt.black))
1480 head_pen.setWidth(1)
1482 text_pen = QtGui.QPen()
1483 text_pen.setColor(QtGui.QColor(Qt.black))
1484 text_pen.setWidth(1)
1486 border = 1
1487 item_spacing = 8
1488 text_x_offset = 3
1489 text_y_offset = 0
1491 def __init__(self, commit):
1492 QtWidgets.QGraphicsItem.__init__(self)
1493 self.setZValue(-1)
1494 self.commit = commit
1496 def type(self):
1497 return self.item_type
1499 def boundingRect(self, cache=Cache):
1500 QPainterPath = QtGui.QPainterPath
1501 QRectF = QtCore.QRectF
1503 width = 72
1504 height = 18
1505 current_width = 0
1506 spacing = self.item_spacing
1507 border_x = self.border + self.text_x_offset
1508 border_y = self.border + self.text_y_offset
1510 font = cache.label_font()
1511 item_shape = QPainterPath()
1513 base_rect = QRectF(0, 0, width, height)
1514 base_rect = base_rect.adjusted(-border_x, -border_y, border_x, border_y)
1515 item_shape.addRect(base_rect)
1517 for tag in self.commit.tags:
1518 text_shape = QPainterPath()
1519 text_shape.addText(current_width, 0, font, tag)
1520 text_rect = text_shape.boundingRect()
1521 box_rect = text_rect.adjusted(-border_x, -border_y, border_x, border_y)
1522 item_shape.addRect(box_rect)
1523 current_width = item_shape.boundingRect().width() + spacing
1525 return item_shape.boundingRect()
1527 def paint(self, painter, _option, _widget, cache=Cache):
1528 # Draw tags and branches
1529 font = cache.label_font()
1530 painter.setFont(font)
1532 current_width = 3
1533 border = self.border
1534 x_offset = self.text_x_offset
1535 y_offset = self.text_y_offset
1536 spacing = self.item_spacing
1537 QRectF = QtCore.QRectF
1539 HEAD = 'HEAD'
1540 remotes_prefix = 'remotes/'
1541 tags_prefix = 'tags/'
1542 heads_prefix = 'heads/'
1543 remotes_len = len(remotes_prefix)
1544 tags_len = len(tags_prefix)
1545 heads_len = len(heads_prefix)
1547 for tag in self.commit.tags:
1548 if tag == HEAD:
1549 painter.setPen(self.text_pen)
1550 painter.setBrush(self.remote_color)
1551 elif tag.startswith(remotes_prefix):
1552 tag = tag[remotes_len:]
1553 painter.setPen(self.text_pen)
1554 painter.setBrush(self.other_color)
1555 elif tag.startswith(tags_prefix):
1556 tag = tag[tags_len:]
1557 painter.setPen(self.text_pen)
1558 painter.setBrush(self.remote_color)
1559 elif tag.startswith(heads_prefix):
1560 tag = tag[heads_len:]
1561 painter.setPen(self.head_pen)
1562 painter.setBrush(self.head_color)
1563 else:
1564 painter.setPen(self.text_pen)
1565 painter.setBrush(self.other_color)
1567 text_rect = painter.boundingRect(
1568 QRectF(current_width, 0, 0, 0), Qt.TextSingleLine, tag
1570 box_rect = text_rect.adjusted(-x_offset, -y_offset, x_offset, y_offset)
1572 painter.drawRoundedRect(box_rect, border, border)
1573 painter.drawText(text_rect, Qt.TextSingleLine, tag)
1574 current_width += text_rect.width() + spacing
1577 class GraphView(QtWidgets.QGraphicsView, ViewerMixin):
1578 commits_selected = Signal(object)
1579 diff_commits = Signal(object, object)
1581 x_adjust = int(Commit.commit_radius * 4 / 3)
1582 y_adjust = int(Commit.commit_radius * 4 / 3)
1584 x_off = -18
1585 y_off = -20
1587 def __init__(self, context, parent):
1588 QtWidgets.QGraphicsView.__init__(self, parent)
1589 ViewerMixin.__init__(self)
1590 EdgeColor.update_colors(context.app.theme)
1592 theme = context.app.theme
1593 highlight = theme.selection_color()
1594 Commit.commit_selected_color = highlight
1595 Commit.selected_outline_color = highlight.darker()
1597 self.context = context
1598 self.columns = {}
1599 self.menu_actions = None
1600 self.commits = []
1601 self.items = {}
1602 self.mouse_start = [0, 0]
1603 self.saved_matrix = self.transform()
1604 self.max_column = 0
1605 self.min_column = 0
1606 self.frontier = {}
1607 self.tagged_cells = set()
1609 self.x_start = 24
1610 self.x_min = 24
1611 self.x_offsets = collections.defaultdict(lambda: self.x_min)
1613 self.is_panning = False
1614 self.pressed = False
1615 self.selecting = False
1616 self.last_mouse = [0, 0]
1617 self.zoom = 2
1618 self.setDragMode(self.RubberBandDrag)
1620 scene = QtWidgets.QGraphicsScene(self)
1621 scene.setItemIndexMethod(QtWidgets.QGraphicsScene.BspTreeIndex)
1622 scene.selectionChanged.connect(self.selection_changed)
1623 self.setScene(scene)
1625 self.setRenderHint(QtGui.QPainter.Antialiasing)
1626 self.setViewportUpdateMode(self.SmartViewportUpdate)
1627 self.setCacheMode(QtWidgets.QGraphicsView.CacheBackground)
1628 self.setTransformationAnchor(QtWidgets.QGraphicsView.AnchorUnderMouse)
1629 self.setResizeAnchor(QtWidgets.QGraphicsView.NoAnchor)
1631 background_color = qtutils.css_color(context.app.theme.background_color_rgb())
1632 self.setBackgroundBrush(background_color)
1634 qtutils.add_action(
1635 self,
1636 N_('Zoom In'),
1637 self.zoom_in,
1638 hotkeys.ZOOM_IN,
1639 hotkeys.ZOOM_IN_SECONDARY,
1642 qtutils.add_action(self, N_('Zoom Out'), self.zoom_out, hotkeys.ZOOM_OUT)
1644 qtutils.add_action(self, N_('Zoom to Fit'), self.zoom_to_fit, hotkeys.FIT)
1646 qtutils.add_action(
1647 self, N_('Select Parent'), self._select_parent, hotkeys.MOVE_DOWN_TERTIARY
1650 qtutils.add_action(
1651 self,
1652 N_('Select Oldest Parent'),
1653 self._select_oldest_parent,
1654 hotkeys.MOVE_DOWN,
1657 qtutils.add_action(
1658 self, N_('Select Child'), self._select_child, hotkeys.MOVE_UP_TERTIARY
1661 qtutils.add_action(
1662 self, N_('Select Newest Child'), self._select_newest_child, hotkeys.MOVE_UP
1665 def clear(self):
1666 EdgeColor.reset()
1667 self.scene().clear()
1668 self.items.clear()
1669 self.x_offsets.clear()
1670 self.x_min = 24
1671 self.commits = []
1673 # ViewerMixin interface
1674 def selected_items(self):
1675 """Return the currently selected items"""
1676 return self.scene().selectedItems()
1678 def zoom_in(self):
1679 self.scale_view(1.5)
1681 def zoom_out(self):
1682 self.scale_view(1.0 / 1.5)
1684 def selection_changed(self):
1685 # Broadcast selection to other widgets
1686 selected_items = self.scene().selectedItems()
1687 commits = sort_by_generation([item.commit for item in selected_items])
1688 self.set_selecting(True)
1689 self.commits_selected.emit(commits)
1690 self.set_selecting(False)
1692 def select_commits(self, commits):
1693 if self.selecting:
1694 return
1695 with qtutils.BlockSignals(self.scene()):
1696 self.select([commit.oid for commit in commits])
1698 def select(self, oids):
1699 """Select the item for the oids"""
1700 self.scene().clearSelection()
1701 for oid in oids:
1702 try:
1703 item = self.items[oid]
1704 except KeyError:
1705 continue
1706 item.setSelected(True)
1707 item_rect = item.sceneTransform().mapRect(item.boundingRect())
1708 self.ensureVisible(item_rect)
1710 def _get_item_by_generation(self, commits, criteria_func):
1711 """Return the item for the commit matching criteria"""
1712 if not commits:
1713 return None
1714 generation = None
1715 for commit in commits:
1716 if generation is None or criteria_func(generation, commit.generation):
1717 oid = commit.oid
1718 generation = commit.generation
1719 try:
1720 return self.items[oid]
1721 except KeyError:
1722 return None
1724 def _oldest_item(self, commits):
1725 """Return the item for the commit with the oldest generation number"""
1726 return self._get_item_by_generation(commits, lambda a, b: a > b)
1728 def _newest_item(self, commits):
1729 """Return the item for the commit with the newest generation number"""
1730 return self._get_item_by_generation(commits, lambda a, b: a < b)
1732 def create_patch(self):
1733 items = self.selected_items()
1734 if not items:
1735 return
1736 context = self.context
1737 selected_commits = sort_by_generation([n.commit for n in items])
1738 oids = [c.oid for c in selected_commits]
1739 all_oids = [c.oid for c in sort_by_generation(self.commits)]
1740 cmds.do(cmds.FormatPatch, context, oids, all_oids)
1742 def _select_parent(self):
1743 """Select the parent with the newest generation number"""
1744 selected_item = self.selected_item()
1745 if selected_item is None:
1746 return
1747 parent_item = self._newest_item(selected_item.commit.parents)
1748 if parent_item is None:
1749 return
1750 selected_item.setSelected(False)
1751 parent_item.setSelected(True)
1752 self.ensureVisible(parent_item.mapRectToScene(parent_item.boundingRect()))
1754 def _select_oldest_parent(self):
1755 """Select the parent with the oldest generation number"""
1756 selected_item = self.selected_item()
1757 if selected_item is None:
1758 return
1759 parent_item = self._oldest_item(selected_item.commit.parents)
1760 if parent_item is None:
1761 return
1762 selected_item.setSelected(False)
1763 parent_item.setSelected(True)
1764 scene_rect = parent_item.mapRectToScene(parent_item.boundingRect())
1765 self.ensureVisible(scene_rect)
1767 def _select_child(self):
1768 """Select the child with the oldest generation number"""
1769 selected_item = self.selected_item()
1770 if selected_item is None:
1771 return
1772 child_item = self._oldest_item(selected_item.commit.children)
1773 if child_item is None:
1774 return
1775 selected_item.setSelected(False)
1776 child_item.setSelected(True)
1777 scene_rect = child_item.mapRectToScene(child_item.boundingRect())
1778 self.ensureVisible(scene_rect)
1780 def _select_newest_child(self):
1781 """Select the Nth child with the newest generation number (N > 1)"""
1782 selected_item = self.selected_item()
1783 if selected_item is None:
1784 return
1785 if len(selected_item.commit.children) > 1:
1786 children = selected_item.commit.children[1:]
1787 else:
1788 children = selected_item.commit.children
1789 child_item = self._newest_item(children)
1790 if child_item is None:
1791 return
1792 selected_item.setSelected(False)
1793 child_item.setSelected(True)
1794 scene_rect = child_item.mapRectToScene(child_item.boundingRect())
1795 self.ensureVisible(scene_rect)
1797 def set_initial_view(self):
1798 items = []
1799 selected = self.selected_items()
1800 if selected:
1801 items.extend(selected)
1803 if not selected and self.commits:
1804 commit = self.commits[-1]
1805 items.append(self.items[commit.oid])
1807 bounds = self.scene().itemsBoundingRect()
1808 bounds.adjust(-64, 0, 0, 0)
1809 self.setSceneRect(bounds)
1810 self.fit_view_to_items(items)
1812 def zoom_to_fit(self):
1813 """Fit selected items into the viewport"""
1814 items = self.selected_items()
1815 self.fit_view_to_items(items)
1817 def fit_view_to_items(self, items):
1818 if not items:
1819 rect = self.scene().itemsBoundingRect()
1820 else:
1821 x_min = y_min = maxsize
1822 x_max = y_max = -maxsize
1824 for item in items:
1825 pos = item.pos()
1826 x_val = pos.x()
1827 y_val = pos.y()
1828 x_min = min(x_min, x_val)
1829 x_max = max(x_max, x_val)
1830 y_min = min(y_min, y_val)
1831 y_max = max(y_max, y_val)
1833 rect = QtCore.QRectF(x_min, y_min, abs(x_max - x_min), abs(y_max - y_min))
1835 x_adjust = abs(GraphView.x_adjust)
1836 y_adjust = abs(GraphView.y_adjust)
1838 count = max(2.0, 10.0 - len(items) / 2.0)
1839 y_offset = int(y_adjust * count)
1840 x_offset = int(x_adjust * count)
1841 rect.setX(rect.x() - x_offset // 2)
1842 rect.setY(rect.y() - y_adjust // 2)
1843 rect.setHeight(rect.height() + y_offset)
1844 rect.setWidth(rect.width() + x_offset)
1846 self.fitInView(rect, Qt.KeepAspectRatio)
1847 self.scene().invalidate()
1849 def handle_event(self, event_handler, event, update=True):
1850 event_handler(self, event)
1851 if update:
1852 self.update()
1854 def set_selecting(self, selecting):
1855 self.selecting = selecting
1857 def pan(self, event):
1858 pos = event.pos()
1859 x_offset = pos.x() - self.mouse_start[0]
1860 y_offset = pos.y() - self.mouse_start[1]
1862 if x_offset == 0 and y_offset == 0:
1863 return
1865 rect = QtCore.QRect(0, 0, abs(x_offset), abs(y_offset))
1866 delta = self.mapToScene(rect).boundingRect()
1868 x_translate = delta.width()
1869 if x_offset < 0.0:
1870 x_translate = -x_translate
1872 y_translate = delta.height()
1873 if y_offset < 0.0:
1874 y_translate = -y_translate
1876 matrix = self.transform()
1877 matrix.reset()
1878 matrix *= self.saved_matrix
1879 matrix.translate(x_translate, y_translate)
1881 self.setTransformationAnchor(QtWidgets.QGraphicsView.NoAnchor)
1882 self.setTransform(matrix)
1884 def wheel_zoom(self, event):
1885 """Handle mouse wheel zooming."""
1886 delta = qtcompat.wheel_delta(event)
1887 zoom = math.pow(2.0, delta / 512.0)
1888 factor = (
1889 self.transform()
1890 .scale(zoom, zoom)
1891 .mapRect(QtCore.QRectF(0.0, 0.0, 1.0, 1.0))
1892 .width()
1894 if factor < 0.014 or factor > 42.0:
1895 return
1896 self.setTransformationAnchor(QtWidgets.QGraphicsView.AnchorUnderMouse)
1897 self.zoom = zoom
1898 self.scale(zoom, zoom)
1900 def wheel_pan(self, event):
1901 """Handle mouse wheel panning."""
1902 unit = QtCore.QRectF(0.0, 0.0, 1.0, 1.0)
1903 factor = 1.0 / self.transform().mapRect(unit).width()
1904 tx, ty = qtcompat.wheel_translation(event)
1906 matrix = self.transform().translate(tx * factor, ty * factor)
1907 self.setTransformationAnchor(QtWidgets.QGraphicsView.NoAnchor)
1908 self.setTransform(matrix)
1910 def scale_view(self, scale):
1911 factor = (
1912 self.transform()
1913 .scale(scale, scale)
1914 .mapRect(QtCore.QRectF(0, 0, 1, 1))
1915 .width()
1917 if factor < 0.07 or factor > 100.0:
1918 return
1919 self.zoom = scale
1921 adjust_scrollbars = False
1922 scrollbar = self.verticalScrollBar()
1923 scrollbar_offset = 1.0
1924 if scrollbar:
1925 value = get(scrollbar)
1926 minimum = scrollbar.minimum()
1927 maximum = scrollbar.maximum()
1928 scrollbar_range = maximum - minimum
1929 distance = value - minimum
1930 nonzero_range = scrollbar_range > 0.1
1931 if nonzero_range:
1932 scrollbar_offset = distance / scrollbar_range
1933 adjust_scrollbars = True
1935 self.setTransformationAnchor(QtWidgets.QGraphicsView.NoAnchor)
1936 self.scale(scale, scale)
1938 scrollbar = self.verticalScrollBar()
1939 if scrollbar and adjust_scrollbars:
1940 minimum = scrollbar.minimum()
1941 maximum = scrollbar.maximum()
1942 scrollbar_range = maximum - minimum
1943 value = minimum + int(float(scrollbar_range) * scrollbar_offset)
1944 scrollbar.setValue(value)
1946 def add_commits(self, commits):
1947 """Traverse commits and add them to the view."""
1948 self.commits.extend(commits)
1949 scene = self.scene()
1950 for commit in commits:
1951 item = Commit(commit)
1952 self.items[commit.oid] = item
1953 for ref in commit.tags:
1954 self.items[ref] = item
1955 scene.addItem(item)
1957 self.layout_commits()
1958 self.link(commits)
1960 def link(self, commits):
1961 """Create edges linking commits with their parents"""
1962 scene = self.scene()
1963 for commit in commits:
1964 try:
1965 commit_item = self.items[commit.oid]
1966 except KeyError:
1967 continue # The history is truncated.
1968 for parent in reversed(commit.parents):
1969 try:
1970 parent_item = self.items[parent.oid]
1971 except KeyError:
1972 continue # The history is truncated.
1973 try:
1974 edge = parent_item.edges[commit.oid]
1975 except KeyError:
1976 edge = Edge(parent_item, commit_item)
1977 else:
1978 continue
1979 parent_item.edges[commit.oid] = edge
1980 commit_item.edges[parent.oid] = edge
1981 scene.addItem(edge)
1983 def layout_commits(self):
1984 positions = self.position_nodes()
1986 # Each edge is accounted in two commits. Hence, accumulate invalid
1987 # edges to prevent double edge invalidation.
1988 invalid_edges = set()
1990 for oid, (x_val, y_val) in positions.items():
1991 item = self.items[oid]
1993 pos = item.pos()
1994 if pos != (x_val, y_val):
1995 item.setPos(x_val, y_val)
1997 for edge in item.edges.values():
1998 invalid_edges.add(edge)
2000 for edge in invalid_edges:
2001 edge.commits_were_invalidated()
2003 # Commit node layout technique
2005 # Nodes are aligned by a mesh. Columns and rows are distributed using
2006 # algorithms described below.
2008 # Row assignment algorithm
2010 # The algorithm aims consequent.
2011 # 1. A commit should be above all its parents.
2012 # 2. No commit should be at right side of a commit with a tag in same row.
2013 # This prevents overlapping of tag labels with commits and other labels.
2014 # 3. Commit density should be maximized.
2016 # The algorithm requires that all parents of a commit were assigned column.
2017 # Nodes must be traversed in generation ascend order. This guarantees that all
2018 # parents of a commit were assigned row. So, the algorithm may operate in
2019 # course of column assignment algorithm.
2021 # Row assignment uses frontier. A frontier is a dictionary that contains
2022 # minimum available row index for each column. It propagates during the
2023 # algorithm. Set of cells with tags is also maintained to meet second aim.
2025 # Initialization is performed by reset_rows method. Each new column should
2026 # be declared using declare_column method. Getting row for a cell is
2027 # implemented in alloc_cell method. Frontier must be propagated for any child
2028 # of fork commit which occupies different column. This meets first aim.
2030 # Column assignment algorithm
2032 # The algorithm traverses nodes in generation ascend order. This guarantees
2033 # that a node will be visited after all its parents.
2035 # The set of occupied columns are maintained during work. Initially it is
2036 # empty and no node occupied a column. Empty columns are allocated on demand.
2037 # Free index for column being allocated is searched in following way.
2038 # 1. Start from desired column and look towards graph center (0 column).
2039 # 2. Start from center and look in both directions simultaneously.
2040 # Desired column is defaulted to 0. Fork node should set desired column for
2041 # children equal to its one. This prevents branch from jumping too far from
2042 # its fork.
2044 # Initialization is performed by reset_columns method. Column allocation is
2045 # implemented in alloc_column method. Initialization and main loop are in
2046 # recompute_grid method. The method also embeds row assignment algorithm by
2047 # implementation.
2049 # Actions for each node are follow.
2050 # 1. If the node was not assigned a column then it is assigned empty one.
2051 # 2. Allocate row.
2052 # 3. Allocate columns for children.
2053 # If a child have a column assigned then it should no be overridden. One of
2054 # children is assigned same column as the node. If the node is a fork then the
2055 # child is chosen in generation descent order. This is a heuristic and it only
2056 # affects resulting appearance of the graph. Other children are assigned empty
2057 # columns in same order. It is the heuristic too.
2058 # 4. If no child occupies column of the node then leave it.
2059 # It is possible in consequent situations.
2060 # 4.1 The node is a leaf.
2061 # 4.2 The node is a fork and all its children are already assigned side
2062 # column. It is possible if all the children are merges.
2063 # 4.3 Single node child is a merge that is already assigned a column.
2064 # 5. Propagate frontier with respect to this node.
2065 # Each frontier entry corresponding to column occupied by any node's child
2066 # must be gather than node row index. This meets first aim of the row
2067 # assignment algorithm.
2068 # Note that frontier of child that occupies same row was propagated during
2069 # step 2. Hence, it must be propagated for children on side columns.
2071 def reset_columns(self):
2072 # Some children of displayed commits might not be accounted in
2073 # 'commits' list. It is common case during loading of big graph.
2074 # But, they are assigned a column that must be reset. Hence, use
2075 # depth-first traversal to reset all columns assigned.
2076 for node in self.commits:
2077 if node.column is None:
2078 continue
2079 stack = [node]
2080 while stack:
2081 node = stack.pop()
2082 node.column = None
2083 for child in node.children:
2084 if child.column is not None:
2085 stack.append(child)
2087 self.columns = {}
2088 self.max_column = 0
2089 self.min_column = 0
2091 def reset_rows(self):
2092 self.frontier = {}
2093 self.tagged_cells = set()
2095 def declare_column(self, column):
2096 if self.frontier:
2097 # Align new column frontier by frontier of nearest column. If all
2098 # columns were left then select maximum frontier value.
2099 if not self.columns:
2100 self.frontier[column] = max(list(self.frontier.values()))
2101 return
2102 # This is heuristic that mostly affects roots. Note that the
2103 # frontier values for fork children will be overridden in course of
2104 # propagate_frontier.
2105 for offset in itertools.count(1):
2106 for value in (column + offset, column - offset):
2107 if value not in self.columns:
2108 # Column is not occupied.
2109 continue
2110 try:
2111 frontier = self.frontier[value]
2112 except KeyError:
2113 # Column 'c' was never allocated.
2114 continue
2116 frontier -= 1
2117 # The frontier of the column may be higher because of
2118 # tag overlapping prevention performed for previous head.
2119 try:
2120 if self.frontier[column] >= frontier:
2121 break
2122 except KeyError:
2123 pass
2125 self.frontier[column] = frontier
2126 break
2127 else:
2128 continue
2129 break
2130 else:
2131 # First commit must be assigned 0 row.
2132 self.frontier[column] = 0
2134 def alloc_column(self, column=0):
2135 columns = self.columns
2136 # First, look for free column by moving from desired column to graph
2137 # center (column 0).
2138 for c in range(column, 0, -1 if column > 0 else 1):
2139 if c not in columns:
2140 if c > self.max_column:
2141 self.max_column = c
2142 elif c < self.min_column:
2143 self.min_column = c
2144 break
2145 else:
2146 # If no free column was found between graph center and desired
2147 # column then look for free one by moving from center along both
2148 # directions simultaneously.
2149 for c in itertools.count(0):
2150 if c not in columns:
2151 if c > self.max_column:
2152 self.max_column = c
2153 break
2154 c = -c
2155 if c not in columns:
2156 if c < self.min_column:
2157 self.min_column = c
2158 break
2159 self.declare_column(c)
2160 columns[c] = 1
2161 return c
2163 def alloc_cell(self, column, tags):
2164 # Get empty cell from frontier.
2165 cell_row = self.frontier[column]
2167 if tags:
2168 # Prevent overlapping of tag with cells already allocated a row.
2169 if self.x_off > 0:
2170 can_overlap = list(range(column + 1, self.max_column + 1))
2171 else:
2172 can_overlap = list(range(column - 1, self.min_column - 1, -1))
2173 for value in can_overlap:
2174 frontier = self.frontier[value]
2175 if frontier > cell_row:
2176 cell_row = frontier
2178 # Avoid overlapping with tags of commits at cell_row.
2179 if self.x_off > 0:
2180 can_overlap = list(range(self.min_column, column))
2181 else:
2182 can_overlap = list(range(self.max_column, column, -1))
2183 for cell_row in itertools.count(cell_row):
2184 for value in can_overlap:
2185 if (value, cell_row) in self.tagged_cells:
2186 # Overlapping. Try next row.
2187 break
2188 else:
2189 # No overlapping was found.
2190 break
2191 # Note that all checks should be made for new cell_row value.
2193 if tags:
2194 self.tagged_cells.add((column, cell_row))
2196 # Propagate frontier.
2197 self.frontier[column] = cell_row + 1
2198 return cell_row
2200 def propagate_frontier(self, column, value):
2201 current = self.frontier[column]
2202 if current < value:
2203 self.frontier[column] = value
2205 def leave_column(self, column):
2206 count = self.columns[column]
2207 if count == 1:
2208 del self.columns[column]
2209 else:
2210 self.columns[column] = count - 1
2212 def recompute_grid(self):
2213 self.reset_columns()
2214 self.reset_rows()
2216 for node in sort_by_generation(list(self.commits)):
2217 if node.column is None:
2218 # Node is either root or its parent is not in items. This
2219 # happens when tree loading is in progress. Allocate new
2220 # columns for such nodes.
2221 node.column = self.alloc_column()
2223 node.row = self.alloc_cell(node.column, node.tags)
2225 # Allocate columns for children which are still without one. Also
2226 # propagate frontier for children.
2227 if node.is_fork():
2228 sorted_children = sorted(
2229 node.children, key=lambda c: c.generation, reverse=True
2231 citer = iter(sorted_children)
2232 for child in citer:
2233 if child.column is None:
2234 # Top most child occupies column of parent.
2235 child.column = node.column
2236 # Note that frontier is propagated in course of
2237 # alloc_cell.
2238 break
2239 self.propagate_frontier(child.column, node.row + 1)
2240 else:
2241 # No child occupies same column.
2242 self.leave_column(node.column)
2243 # Note that the loop below will pass no iteration.
2245 # Rest children are allocated new column.
2246 for child in citer:
2247 if child.column is None:
2248 child.column = self.alloc_column(node.column)
2249 self.propagate_frontier(child.column, node.row + 1)
2250 elif node.children:
2251 child = node.children[0]
2252 if child.column is None:
2253 child.column = node.column
2254 # Note that frontier is propagated in course of alloc_cell.
2255 elif child.column != node.column:
2256 # Child node have other parents and occupies column of one
2257 # of them.
2258 self.leave_column(node.column)
2259 # But frontier must be propagated with respect to this
2260 # parent.
2261 self.propagate_frontier(child.column, node.row + 1)
2262 else:
2263 # This is a leaf node.
2264 self.leave_column(node.column)
2266 def position_nodes(self):
2267 self.recompute_grid()
2269 x_start = self.x_start
2270 x_min = self.x_min
2271 x_off = self.x_off
2272 y_off = self.y_off
2274 positions = {}
2276 for node in self.commits:
2277 x_val = x_start + node.column * x_off
2278 y_val = y_off + node.row * y_off
2280 positions[node.oid] = (x_val, y_val)
2281 x_min = min(x_min, x_val)
2283 self.x_min = x_min
2285 return positions
2287 # Qt overrides
2288 def contextMenuEvent(self, event):
2289 self.context_menu_event(event)
2291 def mousePressEvent(self, event):
2292 if event.button() == Qt.MidButton:
2293 pos = event.pos()
2294 self.mouse_start = [pos.x(), pos.y()]
2295 self.saved_matrix = self.transform()
2296 self.is_panning = True
2297 return
2298 if event.button() == Qt.RightButton:
2299 event.ignore()
2300 return
2301 if event.button() == Qt.LeftButton:
2302 self.pressed = True
2303 self.handle_event(QtWidgets.QGraphicsView.mousePressEvent, event)
2305 def mouseMoveEvent(self, event):
2306 if self.is_panning:
2307 self.pan(event)
2308 return
2309 pos = self.mapToScene(event.pos())
2310 self.last_mouse[0] = pos.x()
2311 self.last_mouse[1] = pos.y()
2312 self.handle_event(QtWidgets.QGraphicsView.mouseMoveEvent, event, update=False)
2314 def mouseReleaseEvent(self, event):
2315 self.pressed = False
2316 if event.button() == Qt.MidButton:
2317 self.is_panning = False
2318 return
2319 self.handle_event(QtWidgets.QGraphicsView.mouseReleaseEvent, event)
2320 self.viewport().repaint()
2322 def wheelEvent(self, event):
2323 """Handle Qt mouse wheel events."""
2324 if event.modifiers() & Qt.ControlModifier:
2325 self.wheel_zoom(event)
2326 else:
2327 self.wheel_pan(event)
2329 def fitInView(self, rect, flags=Qt.IgnoreAspectRatio):
2330 """Override fitInView to remove unwanted margins
2332 https://bugreports.qt.io/browse/QTBUG-42331 - based on QT sources
2335 if self.scene() is None or rect.isNull():
2336 return
2337 unity = self.transform().mapRect(QtCore.QRectF(0, 0, 1, 1))
2338 self.scale(1.0 / unity.width(), 1.0 / unity.height())
2339 view_rect = self.viewport().rect()
2340 scene_rect = self.transform().mapRect(rect)
2341 xratio = view_rect.width() / scene_rect.width()
2342 yratio = view_rect.height() / scene_rect.height()
2343 if flags == Qt.KeepAspectRatio:
2344 xratio = yratio = min(xratio, yratio)
2345 elif flags == Qt.KeepAspectRatioByExpanding:
2346 xratio = yratio = max(xratio, yratio)
2347 self.scale(xratio, yratio)
2348 self.centerOn(rect.center())
2351 def sort_by_generation(commits):
2352 """Sort commits by their generation. Ensures consistent diffs and patch exports"""
2353 if len(commits) <= 1:
2354 return commits
2355 commits.sort(key=lambda x: x.generation)
2356 return commits
2359 # Glossary
2360 # ========
2361 # oid -- Git objects IDs (i.e. SHA-1 IDs)
2362 # ref -- Git references that resolve to a commit-ish (HEAD, branches, tags)