dag: use a consistent sort order when selecting commits
[git-cola.git] / cola / widgets / dag.py
blob75cf2bf790971000e24429fb4833ce75e07572c3
1 from __future__ import absolute_import, division, print_function, unicode_literals
2 import collections
3 import itertools
4 import math
5 from functools import partial
7 from qtpy.QtCore import Qt
8 from qtpy.QtCore import Signal
9 from qtpy import QtCore
10 from qtpy import QtGui
11 from qtpy import QtWidgets
13 from ..compat import maxsize
14 from ..i18n import N_
15 from ..models import dag
16 from ..models import main
17 from ..qtutils import get
18 from .. import core
19 from .. import cmds
20 from .. import difftool
21 from .. import gitcmds
22 from .. import guicmds
23 from .. import hotkeys
24 from .. import icons
25 from .. import qtcompat
26 from .. import qtutils
27 from .. import utils
28 from . import archive
29 from . import browse
30 from . import completion
31 from . import createbranch
32 from . import createtag
33 from . import defs
34 from . import diff
35 from . import filelist
36 from . import standard
39 def git_dag(context, args=None, existing_view=None, show=True):
40 """Return a pre-populated git DAG widget."""
41 model = context.model
42 branch = model.currentbranch
43 # disambiguate between branch names and filenames by using '--'
44 branch_doubledash = (branch + ' --') if branch else ''
45 params = dag.DAG(branch_doubledash, 1000)
46 params.set_arguments(args)
48 if existing_view is None:
49 view = GitDAG(context, params)
50 else:
51 view = existing_view
52 view.set_params(params)
53 if params.ref:
54 view.display()
55 if show:
56 view.show()
57 return view
60 class FocusRedirectProxy(object):
61 """Redirect actions from the main widget to child widgets"""
63 def __init__(self, *widgets):
64 """Provide proxied widgets; the default widget must be first"""
65 self.widgets = widgets
66 self.default = widgets[0]
68 def __getattr__(self, name):
69 return lambda *args, **kwargs: self._forward_action(name, *args, **kwargs)
71 def _forward_action(self, name, *args, **kwargs):
72 """Forward the captured action to the focused or default widget"""
73 widget = QtWidgets.QApplication.focusWidget()
74 if widget in self.widgets and hasattr(widget, name):
75 fn = getattr(widget, name)
76 else:
77 fn = getattr(self.default, name)
79 return fn(*args, **kwargs)
82 class ViewerMixin(object):
83 """Implementations must provide selected_items()"""
85 def __init__(self):
86 self.context = None # provided by implementation
87 self.selected = None
88 self.clicked = None
89 self.menu_actions = None # provided by implementation
91 def selected_item(self):
92 """Return the currently selected item"""
93 selected_items = self.selected_items()
94 if not selected_items:
95 return None
96 return selected_items[0]
98 def selected_oid(self):
99 """Return the currently selected commit object ID"""
100 item = self.selected_item()
101 if item is None:
102 result = None
103 else:
104 result = item.commit.oid
105 return result
107 def selected_oids(self):
108 """Return the currently selected comit object IDs"""
109 return [i.commit for i in self.selected_items()]
111 def clicked_oid(self):
112 """Return the clicked or selected commit object ID"""
113 if self.clicked:
114 return self.clicked.oid
115 return self.selected_oid()
117 def with_oid(self, fn):
118 """Run an operation with a commit object ID"""
119 oid = self.clicked_oid()
120 if oid:
121 result = fn(oid)
122 else:
123 result = None
124 return result
126 def with_selected_oid(self, fn):
127 """Run an operation with a commit object ID"""
128 oid = self.selected_oid()
129 if oid:
130 result = fn(oid)
131 else:
132 result = None
133 return result
135 def diff_selected_this(self):
136 """Diff the selected commit against the clicked commit"""
137 clicked_oid = self.clicked.oid
138 selected_oid = self.selected.oid
139 self.diff_commits.emit(selected_oid, clicked_oid)
141 def diff_this_selected(self):
142 """Diff the clicked commit against the selected commit"""
143 clicked_oid = self.clicked.oid
144 selected_oid = self.selected.oid
145 self.diff_commits.emit(clicked_oid, selected_oid)
147 def cherry_pick(self):
148 """Cherry-pick a commit using git cherry-pick"""
149 context = self.context
150 self.with_oid(lambda oid: cmds.do(cmds.CherryPick, context, [oid]))
152 def revert(self):
153 """Revert a commit using git revert"""
154 context = self.context
155 self.with_oid(lambda oid: cmds.do(cmds.Revert, context, oid))
157 def copy_to_clipboard(self):
158 """Copy the current commit object ID to the clipboard"""
159 self.with_oid(qtutils.set_clipboard)
161 def checkout_branch(self):
162 """Checkout the clicked/selected branch"""
163 branches = []
164 clicked = self.clicked
165 selected = self.selected_item()
166 if clicked:
167 branches.extend(clicked.branches)
168 if selected:
169 branches.extend(selected.commit.branches)
170 if not branches:
171 return
172 guicmds.checkout_branch(self.context, default=branches[0])
174 def create_branch(self):
175 """Create a branch at the selected commit"""
176 context = self.context
177 create_new_branch = partial(createbranch.create_new_branch, context)
178 self.with_oid(lambda oid: create_new_branch(revision=oid))
180 def create_tag(self):
181 """Create a tag at the selected commit"""
182 context = self.context
183 self.with_oid(lambda oid: createtag.create_tag(context, ref=oid))
185 def create_tarball(self):
186 """Create a tarball from the selected commit"""
187 context = self.context
188 self.with_oid(lambda oid: archive.show_save_dialog(context, oid, parent=self))
190 def show_diff(self):
191 """Show the diff for the selected commit"""
192 context = self.context
193 self.with_oid(
194 lambda oid: difftool.diff_expression(
195 context, self, oid + '^!', hide_expr=False, focus_tree=True
199 def show_dir_diff(self):
200 """Show a full directory diff for the selected commit"""
201 context = self.context
202 self.with_oid(
203 lambda oid: cmds.difftool_launch(
204 context, left=oid, left_take_magic=True, dir_diff=True
208 def rebase_to_commit(self):
209 """Rebase the current branch to the selected commit"""
210 context = self.context
211 self.with_oid(lambda oid: cmds.do(cmds.Rebase, context, upstream=oid))
213 def reset_mixed(self):
214 """Reset the repository using git reset --mixed"""
215 context = self.context
216 self.with_oid(lambda oid: cmds.do(cmds.ResetMixed, context, ref=oid))
218 def reset_keep(self):
219 """Reset the repository using git reset --keep"""
220 context = self.context
221 self.with_oid(lambda oid: cmds.do(cmds.ResetKeep, context, ref=oid))
223 def reset_merge(self):
224 """Reset the repository using git reset --merge"""
225 context = self.context
226 self.with_oid(lambda oid: cmds.do(cmds.ResetMerge, context, ref=oid))
228 def reset_soft(self):
229 """Reset the repository using git reset --soft"""
230 context = self.context
231 self.with_oid(lambda oid: cmds.do(cmds.ResetSoft, context, ref=oid))
233 def reset_hard(self):
234 """Reset the repository using git reset --hard"""
235 context = self.context
236 self.with_oid(lambda oid: cmds.do(cmds.ResetHard, context, ref=oid))
238 def restore_worktree(self):
239 """Reset the worktree contents from the selected commit"""
240 context = self.context
241 self.with_oid(lambda oid: cmds.do(cmds.RestoreWorktree, context, ref=oid))
243 def checkout_detached(self):
244 """Checkout a commit using an anonymous detached HEAD"""
245 context = self.context
246 self.with_oid(lambda oid: cmds.do(cmds.Checkout, context, [oid]))
248 def save_blob_dialog(self):
249 """Save a file blob from the selected commit"""
250 context = self.context
251 self.with_oid(lambda oid: browse.BrowseBranch.browse(context, oid))
253 def update_menu_actions(self, event):
254 """Update menu actions to reflect the selection state"""
255 selected_items = self.selected_items()
256 selected_item = self.selected_item()
257 item = self.itemAt(event.pos())
258 if item is None:
259 self.clicked = commit = None
260 else:
261 self.clicked = commit = item.commit
263 has_single_selection = len(selected_items) == 1
264 has_single_selection_or_clicked = bool(has_single_selection or commit)
265 has_selection = bool(selected_items)
266 can_diff = bool(
267 commit and
268 has_single_selection and
269 selected_items and
270 commit is not selected_items[0].commit
272 has_branches = (
273 has_single_selection and
274 selected_item and
275 bool(selected_item.commit.branches)
276 ) or (
277 self.clicked and bool(self.clicked.branches)
280 if can_diff:
281 self.selected = selected_items[0].commit
282 else:
283 self.selected = None
285 self.menu_actions['diff_this_selected'].setEnabled(can_diff)
286 self.menu_actions['diff_selected_this'].setEnabled(can_diff)
287 self.menu_actions['diff_commit'].setEnabled(has_single_selection_or_clicked)
288 self.menu_actions['diff_commit_all'].setEnabled(has_single_selection_or_clicked)
290 self.menu_actions['checkout_branch'].setEnabled(has_branches)
291 self.menu_actions['checkout_detached'].setEnabled(has_single_selection_or_clicked)
292 self.menu_actions['cherry_pick'].setEnabled(has_single_selection_or_clicked)
293 self.menu_actions['copy'].setEnabled(has_single_selection_or_clicked)
294 self.menu_actions['create_branch'].setEnabled(has_single_selection_or_clicked)
295 self.menu_actions['create_patch'].setEnabled(has_selection)
296 self.menu_actions['create_tag'].setEnabled(has_single_selection_or_clicked)
297 self.menu_actions['create_tarball'].setEnabled(has_single_selection_or_clicked)
298 self.menu_actions['rebase_to_commit'].setEnabled(has_single_selection_or_clicked)
299 self.menu_actions['reset_mixed'].setEnabled(has_single_selection_or_clicked)
300 self.menu_actions['reset_keep'].setEnabled(has_single_selection_or_clicked)
301 self.menu_actions['reset_merge'].setEnabled(has_single_selection_or_clicked)
302 self.menu_actions['reset_soft'].setEnabled(has_single_selection_or_clicked)
303 self.menu_actions['reset_hard'].setEnabled(has_single_selection_or_clicked)
304 self.menu_actions['restore_worktree'].setEnabled(has_single_selection_or_clicked)
305 self.menu_actions['revert'].setEnabled(has_single_selection_or_clicked)
306 self.menu_actions['save_blob'].setEnabled(has_single_selection_or_clicked)
308 def context_menu_event(self, event):
309 """Build a context menu and execute it"""
310 self.update_menu_actions(event)
311 menu = qtutils.create_menu(N_('Actions'), self)
312 menu.addAction(self.menu_actions['diff_this_selected'])
313 menu.addAction(self.menu_actions['diff_selected_this'])
314 menu.addAction(self.menu_actions['diff_commit'])
315 menu.addAction(self.menu_actions['diff_commit_all'])
316 menu.addSeparator()
317 menu.addAction(self.menu_actions['checkout_branch'])
318 menu.addAction(self.menu_actions['create_branch'])
319 menu.addAction(self.menu_actions['create_tag'])
320 menu.addAction(self.menu_actions['rebase_to_commit'])
321 menu.addSeparator()
322 menu.addAction(self.menu_actions['cherry_pick'])
323 menu.addAction(self.menu_actions['revert'])
324 menu.addAction(self.menu_actions['create_patch'])
325 menu.addAction(self.menu_actions['create_tarball'])
326 menu.addSeparator()
327 reset_menu = menu.addMenu(N_('Reset'))
328 reset_menu.addAction(self.menu_actions['reset_soft'])
329 reset_menu.addAction(self.menu_actions['reset_mixed'])
330 reset_menu.addAction(self.menu_actions['restore_worktree'])
331 reset_menu.addSeparator()
332 reset_menu.addAction(self.menu_actions['reset_keep'])
333 reset_menu.addAction(self.menu_actions['reset_merge'])
334 reset_menu.addAction(self.menu_actions['reset_hard'])
335 menu.addAction(self.menu_actions['checkout_detached'])
336 menu.addSeparator()
337 menu.addAction(self.menu_actions['save_blob'])
338 menu.addAction(self.menu_actions['copy'])
339 menu.exec_(self.mapToGlobal(event.pos()))
342 def set_icon(icon, action):
343 """ "Set the icon for an action and return the action"""
344 action.setIcon(icon)
345 return action
348 def viewer_actions(widget):
349 """Return commont actions across the tree and graph widgets"""
350 return {
351 'diff_this_selected': set_icon(
352 icons.compare(),
353 qtutils.add_action(
354 widget, N_('Diff this -> selected'), widget.proxy.diff_this_selected
357 'diff_selected_this': set_icon(
358 icons.compare(),
359 qtutils.add_action(
360 widget, N_('Diff selected -> this'), widget.proxy.diff_selected_this
363 'create_branch': set_icon(
364 icons.branch(),
365 qtutils.add_action(widget, N_('Create Branch'), widget.proxy.create_branch),
367 'create_patch': set_icon(
368 icons.save(),
369 qtutils.add_action(widget, N_('Create Patch'), widget.proxy.create_patch),
371 'create_tag': set_icon(
372 icons.tag(),
373 qtutils.add_action(widget, N_('Create Tag'), widget.proxy.create_tag),
375 'create_tarball': set_icon(
376 icons.file_zip(),
377 qtutils.add_action(
378 widget, N_('Save As Tarball/Zip...'), widget.proxy.create_tarball
381 'cherry_pick': set_icon(
382 icons.cherry_pick(),
383 qtutils.add_action(widget, N_('Cherry Pick'), widget.proxy.cherry_pick),
385 'revert': set_icon(
386 icons.undo(), qtutils.add_action(widget, N_('Revert'), widget.proxy.revert)
388 'diff_commit': set_icon(
389 icons.diff(),
390 qtutils.add_action(
391 widget, N_('Launch Diff Tool'), widget.proxy.show_diff, hotkeys.DIFF
394 'diff_commit_all': set_icon(
395 icons.diff(),
396 qtutils.add_action(
397 widget,
398 N_('Launch Directory Diff Tool'),
399 widget.proxy.show_dir_diff,
400 hotkeys.DIFF_SECONDARY,
403 'checkout_branch': set_icon(
404 icons.branch(),
405 qtutils.add_action(
406 widget, N_('Checkout Branch'), widget.proxy.checkout_branch
409 'checkout_detached': qtutils.add_action(
410 widget, N_('Checkout Detached HEAD'), widget.proxy.checkout_detached
412 'rebase_to_commit': set_icon(
413 icons.play(),
414 qtutils.add_action(
415 widget, N_('Rebase to this commit'), widget.proxy.rebase_to_commit
418 'reset_soft': set_icon(
419 icons.style_dialog_reset(),
420 qtutils.add_action(
421 widget, N_('Reset Branch (Soft)'), widget.proxy.reset_soft
424 'reset_mixed': set_icon(
425 icons.style_dialog_reset(),
426 qtutils.add_action(
427 widget, N_('Reset Branch and Stage (Mixed)'), widget.proxy.reset_mixed
430 'reset_keep': set_icon(
431 icons.style_dialog_reset(),
432 qtutils.add_action(
433 widget,
434 N_('Restore Worktree and Reset All (Keep Unstaged Edits)'),
435 widget.proxy.reset_keep,
438 'reset_merge': set_icon(
439 icons.style_dialog_reset(),
440 qtutils.add_action(
441 widget,
442 N_('Restore Worktree and Reset All (Merge)'),
443 widget.proxy.reset_merge,
446 'reset_hard': set_icon(
447 icons.style_dialog_reset(),
448 qtutils.add_action(
449 widget,
450 N_('Restore Worktree and Reset All (Hard)'),
451 widget.proxy.reset_hard,
454 'restore_worktree': set_icon(
455 icons.edit(),
456 qtutils.add_action(
457 widget, N_('Restore Worktree'), widget.proxy.restore_worktree
460 'save_blob': set_icon(
461 icons.save(),
462 qtutils.add_action(
463 widget, N_('Grab File...'), widget.proxy.save_blob_dialog
466 'copy': set_icon(
467 icons.copy(),
468 qtutils.add_action(
469 widget,
470 N_('Copy SHA-1'),
471 widget.proxy.copy_to_clipboard,
472 hotkeys.COPY_SHA1,
478 class CommitTreeWidgetItem(QtWidgets.QTreeWidgetItem):
479 """Custom TreeWidgetItem used in to build the commit tree widget"""
481 def __init__(self, commit, parent=None):
482 QtWidgets.QTreeWidgetItem.__init__(self, parent)
483 self.commit = commit
484 self.setText(0, commit.summary)
485 self.setText(1, commit.author)
486 self.setText(2, commit.authdate)
489 # pylint: disable=too-many-ancestors
490 class CommitTreeWidget(standard.TreeWidget, ViewerMixin):
491 """Display commits using a flat treewidget in "list" mode"""
493 commits_selected = Signal(object)
494 diff_commits = Signal(object, object)
495 zoom_to_fit = Signal()
497 def __init__(self, context, parent):
498 standard.TreeWidget.__init__(self, parent)
499 ViewerMixin.__init__(self)
501 self.setSelectionMode(self.ExtendedSelection)
502 self.setHeaderLabels([N_('Summary'), N_('Author'), N_('Date, Time')])
504 self.context = context
505 self.oidmap = {}
506 self.menu_actions = None
507 self.selecting = False
508 self.commits = []
509 self._adjust_columns = False
511 self.action_up = qtutils.add_action(
512 self, N_('Go Up'), self.go_up, hotkeys.MOVE_UP
515 self.action_down = qtutils.add_action(
516 self, N_('Go Down'), self.go_down, hotkeys.MOVE_DOWN
519 self.zoom_to_fit_action = qtutils.add_action(
520 self, N_('Zoom to Fit'), self.zoom_to_fit.emit, hotkeys.FIT
523 # pylint: disable=no-member
524 self.itemSelectionChanged.connect(self.selection_changed)
526 def export_state(self):
527 """Export the widget's state"""
528 # The base class method is intentionally overridden because we only
529 # care about the details below for this subwidget.
530 state = {}
531 state['column_widths'] = self.column_widths()
532 return state
534 def apply_state(self, state):
535 """Apply the exported widget state"""
536 try:
537 column_widths = state['column_widths']
538 except (KeyError, ValueError):
539 column_widths = None
540 if column_widths:
541 self.set_column_widths(column_widths)
542 else:
543 # Defer showing the columns until we are shown, and our true width
544 # is known. Calling adjust_columns() here ends up with the wrong
545 # answer because we have not yet been parented to the layout.
546 # We set this flag that we process once during our initial
547 # showEvent().
548 self._adjust_columns = True
549 return True
551 # Qt overrides
552 def showEvent(self, event):
553 """Override QWidget::showEvent() to size columns when we are shown"""
554 if self._adjust_columns:
555 self._adjust_columns = False
556 width = self.width()
557 two_thirds = (width * 2) // 3
558 one_sixth = width // 6
560 self.setColumnWidth(0, two_thirds)
561 self.setColumnWidth(1, one_sixth)
562 self.setColumnWidth(2, one_sixth)
563 return standard.TreeWidget.showEvent(self, event)
565 # ViewerMixin
566 def go_up(self):
567 """Select the item above the current item"""
568 self.goto(self.itemAbove)
570 def go_down(self):
571 """Select the item below the current item"""
572 self.goto(self.itemBelow)
574 def goto(self, finder):
575 """Move the selection using a finder strategy"""
576 items = self.selected_items()
577 item = items[0] if items else None
578 if item is None:
579 return
580 found = finder(item)
581 if found:
582 self.select([found.commit.oid])
584 def selected_commit_range(self):
585 """Return a range of selected commits"""
586 selected_items = self.selected_items()
587 if not selected_items:
588 return None, None
589 return selected_items[-1].commit.oid, selected_items[0].commit.oid
591 def set_selecting(self, selecting):
592 """Record the "are we selecting?" status"""
593 self.selecting = selecting
595 def selection_changed(self):
596 """Respond to itemSelectionChanged notifications"""
597 items = self.selected_items()
598 if not items:
599 self.set_selecting(True)
600 self.commits_selected.emit([])
601 self.set_selecting(False)
602 return
603 self.set_selecting(True)
604 self.commits_selected.emit(sort_by_generation([i.commit for i in items]))
605 self.set_selecting(False)
607 def select_commits(self, commits):
608 """Select commits that were selected by the sibling tree/graph widget"""
609 if self.selecting:
610 return
611 with qtutils.BlockSignals(self):
612 self.select([commit.oid for commit in commits])
614 def select(self, oids):
615 """Mark items as selected"""
616 self.clearSelection()
617 if not oids:
618 return
619 for oid in oids:
620 try:
621 item = self.oidmap[oid]
622 except KeyError:
623 continue
624 self.scrollToItem(item)
625 item.setSelected(True)
627 def clear(self):
628 """Clear the tree"""
629 QtWidgets.QTreeWidget.clear(self)
630 self.oidmap.clear()
631 self.commits = []
633 def add_commits(self, commits):
634 """Add commits to the tree"""
635 self.commits.extend(commits)
636 items = []
637 for c in reversed(commits):
638 item = CommitTreeWidgetItem(c)
639 items.append(item)
640 self.oidmap[c.oid] = item
641 for tag in c.tags:
642 self.oidmap[tag] = item
643 self.insertTopLevelItems(0, items)
645 def create_patch(self):
646 """Export a patch from the selected items"""
647 items = self.selectedItems()
648 if not items:
649 return
650 context = self.context
651 oids = [item.commit.oid for item in reversed(items)]
652 all_oids = [c.oid for c in self.commits]
653 cmds.do(cmds.FormatPatch, context, oids, all_oids)
655 # Qt overrides
656 def contextMenuEvent(self, event):
657 """Create a custom context menu and execute it"""
658 self.context_menu_event(event)
660 def mousePressEvent(self, event):
661 """Intercept the right-click event to retain selection state"""
662 item = self.itemAt(event.pos())
663 if item is None:
664 self.clicked = None
665 else:
666 self.clicked = item.commit
667 if event.button() == Qt.RightButton:
668 event.accept()
669 return
670 QtWidgets.QTreeWidget.mousePressEvent(self, event)
673 class GitDAG(standard.MainWindow):
674 """The git-dag widget."""
676 commits_selected = Signal(object)
678 def __init__(self, context, params, parent=None):
679 super(GitDAG, self).__init__(parent)
681 self.setMinimumSize(420, 420)
683 # change when widgets are added/removed
684 self.widget_version = 2
685 self.context = context
686 self.params = params
687 self.model = context.model
689 self.commits = {}
690 self.commit_list = []
691 self.selection = []
692 self.old_refs = set()
693 self.old_oids = None
694 self.old_count = 0
695 self.force_refresh = False
697 self.thread = None
698 self.revtext = completion.GitLogLineEdit(context)
699 self.maxresults = standard.SpinBox()
701 self.zoom_out = qtutils.create_action_button(
702 tooltip=N_('Zoom Out'), icon=icons.zoom_out()
705 self.zoom_in = qtutils.create_action_button(
706 tooltip=N_('Zoom In'), icon=icons.zoom_in()
709 self.zoom_to_fit = qtutils.create_action_button(
710 tooltip=N_('Zoom to Fit'), icon=icons.zoom_fit_best()
713 self.treewidget = CommitTreeWidget(context, self)
714 self.diffwidget = diff.DiffWidget(context, self, is_commit=True)
715 self.filewidget = filelist.FileWidget(context, self)
716 self.graphview = GraphView(context, self)
718 self.treewidget.commits_selected.connect(self.commits_selected)
719 self.graphview.commits_selected.connect(self.commits_selected)
721 self.commits_selected.connect(self.select_commits)
722 self.commits_selected.connect(self.diffwidget.commits_selected)
723 self.commits_selected.connect(self.filewidget.commits_selected)
724 self.commits_selected.connect(self.graphview.select_commits)
725 self.commits_selected.connect(self.treewidget.select_commits)
727 self.filewidget.files_selected.connect(self.diffwidget.files_selected)
728 self.filewidget.difftool_selected.connect(self.difftool_selected)
729 self.filewidget.histories_selected.connect(self.histories_selected)
731 self.proxy = FocusRedirectProxy(
732 self.treewidget, self.graphview, self.filewidget
735 self.viewer_actions = actions = viewer_actions(self)
736 self.treewidget.menu_actions = actions
737 self.graphview.menu_actions = actions
739 self.controls_layout = qtutils.hbox(
740 defs.no_margin, defs.spacing, self.revtext, self.maxresults
743 self.controls_widget = QtWidgets.QWidget()
744 self.controls_widget.setLayout(self.controls_layout)
746 self.log_dock = qtutils.create_dock('Log', N_('Log'), self, stretch=False)
747 self.log_dock.setWidget(self.treewidget)
748 log_dock_titlebar = self.log_dock.titleBarWidget()
749 log_dock_titlebar.add_corner_widget(self.controls_widget)
751 self.file_dock = qtutils.create_dock('Files', N_('Files'), self)
752 self.file_dock.setWidget(self.filewidget)
754 self.diff_options = diff.Options(self.diffwidget)
755 self.diffwidget.set_options(self.diff_options)
756 self.diff_options.hide_advanced_options()
757 self.diff_options.set_diff_type(main.Types.TEXT)
759 self.diff_dock = qtutils.create_dock('Diff', N_('Diff'), self)
760 self.diff_dock.setWidget(self.diffwidget)
762 diff_titlebar = self.diff_dock.titleBarWidget()
763 diff_titlebar.add_corner_widget(self.diff_options)
765 self.graph_controls_layout = qtutils.hbox(
766 defs.no_margin,
767 defs.button_spacing,
768 self.zoom_out,
769 self.zoom_in,
770 self.zoom_to_fit,
771 defs.spacing,
774 self.graph_controls_widget = QtWidgets.QWidget()
775 self.graph_controls_widget.setLayout(self.graph_controls_layout)
777 self.graphview_dock = qtutils.create_dock('Graph', N_('Graph'), self)
778 self.graphview_dock.setWidget(self.graphview)
779 graph_titlebar = self.graphview_dock.titleBarWidget()
780 graph_titlebar.add_corner_widget(self.graph_controls_widget)
782 self.lock_layout_action = qtutils.add_action_bool(
783 self, N_('Lock Layout'), self.set_lock_layout, False
786 self.refresh_action = qtutils.add_action(
787 self, N_('Refresh'), self.refresh, hotkeys.REFRESH
790 # Create the application menu
791 self.menubar = QtWidgets.QMenuBar(self)
792 self.setMenuBar(self.menubar)
794 # View Menu
795 self.view_menu = qtutils.add_menu(N_('View'), self.menubar)
796 self.view_menu.addAction(self.refresh_action)
797 self.view_menu.addAction(self.log_dock.toggleViewAction())
798 self.view_menu.addAction(self.graphview_dock.toggleViewAction())
799 self.view_menu.addAction(self.diff_dock.toggleViewAction())
800 self.view_menu.addAction(self.file_dock.toggleViewAction())
801 self.view_menu.addSeparator()
802 self.view_menu.addAction(self.lock_layout_action)
804 left = Qt.LeftDockWidgetArea
805 right = Qt.RightDockWidgetArea
806 self.addDockWidget(left, self.log_dock)
807 self.addDockWidget(left, self.diff_dock)
808 self.addDockWidget(right, self.graphview_dock)
809 self.addDockWidget(right, self.file_dock)
811 # Also re-loads dag.* from the saved state
812 self.init_state(context.settings, self.resize_to_desktop)
814 qtutils.connect_button(self.zoom_out, self.graphview.zoom_out)
815 qtutils.connect_button(self.zoom_in, self.graphview.zoom_in)
816 qtutils.connect_button(self.zoom_to_fit, self.graphview.zoom_to_fit)
818 self.treewidget.zoom_to_fit.connect(self.graphview.zoom_to_fit)
819 self.treewidget.diff_commits.connect(self.diff_commits)
820 self.graphview.diff_commits.connect(self.diff_commits)
821 self.filewidget.grab_file.connect(self.grab_file)
823 # pylint: disable=no-member
824 self.maxresults.editingFinished.connect(self.display)
826 self.revtext.textChanged.connect(self.text_changed)
827 self.revtext.activated.connect(self.display)
828 self.revtext.enter.connect(self.display)
829 self.revtext.down.connect(self.focus_tree)
831 # The model is updated in another thread so use
832 # signals/slots to bring control back to the main GUI thread
833 self.model.updated.connect(self.model_updated, type=Qt.QueuedConnection)
835 qtutils.add_action(self, 'FocusInput', self.focus_input, hotkeys.FOCUS_INPUT)
836 qtutils.add_action(self, 'FocusTree', self.focus_tree, hotkeys.FOCUS_TREE)
837 qtutils.add_action(self, 'FocusDiff', self.focus_diff, hotkeys.FOCUS_DIFF)
838 qtutils.add_close_action(self)
840 self.set_params(params)
842 def set_params(self, params):
843 context = self.context
844 self.params = params
846 # Update fields affected by model
847 self.revtext.setText(params.ref)
848 self.maxresults.setValue(params.count)
849 self.update_window_title()
851 if self.thread is not None:
852 self.thread.stop()
854 self.thread = ReaderThread(context, params, self)
856 thread = self.thread
857 thread.begin.connect(self.thread_begin, type=Qt.QueuedConnection)
858 thread.status.connect(self.thread_status, type=Qt.QueuedConnection)
859 thread.add.connect(self.add_commits, type=Qt.QueuedConnection)
860 thread.end.connect(self.thread_end, type=Qt.QueuedConnection)
862 def focus_input(self):
863 """Focus the revision input field"""
864 self.revtext.setFocus()
866 def focus_tree(self):
867 """Focus the revision tree list widget"""
868 self.treewidget.setFocus()
870 def focus_diff(self):
871 """Focus the diff widget"""
872 self.diffwidget.setFocus()
874 def text_changed(self, txt):
875 self.params.ref = txt
876 self.update_window_title()
878 def update_window_title(self):
879 project = self.model.project
880 if self.params.ref:
881 self.setWindowTitle(
882 N_('%(project)s: %(ref)s - DAG')
883 % dict(project=project, ref=self.params.ref)
885 else:
886 self.setWindowTitle(project + N_(' - DAG'))
888 def export_state(self):
889 state = standard.MainWindow.export_state(self)
890 state['count'] = self.params.count
891 state['log'] = self.treewidget.export_state()
892 state['word_wrap'] = self.diffwidget.options.enable_word_wrapping.isChecked()
893 return state
895 def apply_state(self, state):
896 result = standard.MainWindow.apply_state(self, state)
897 try:
898 count = state['count']
899 if self.params.overridden('count'):
900 count = self.params.count
901 except (KeyError, TypeError, ValueError, AttributeError):
902 count = self.params.count
903 result = False
904 self.params.set_count(count)
905 self.lock_layout_action.setChecked(state.get('lock_layout', False))
906 self.diffwidget.set_word_wrapping(state.get('word_wrap', False), update=True)
908 try:
909 log_state = state['log']
910 except (KeyError, ValueError):
911 log_state = None
912 if log_state:
913 self.treewidget.apply_state(log_state)
915 return result
917 def model_updated(self):
918 self.display()
919 self.update_window_title()
921 def refresh(self):
922 """Unconditionally refresh the DAG"""
923 # self.force_refresh triggers an Unconditional redraw
924 self.force_refresh = True
925 cmds.do(cmds.Refresh, self.context)
926 self.force_refresh = False
928 def display(self):
929 """Update the view when the Git refs change"""
930 ref = get(self.revtext)
931 count = get(self.maxresults)
932 context = self.context
933 model = self.model
934 # The DAG tries to avoid updating when the object IDs have not
935 # changed. Without doing this the DAG constantly redraws itself
936 # whenever inotify sends update events, which hurts usability.
938 # To minimize redraws we leverage `git rev-parse`. The strategy is to
939 # use `git rev-parse` on the input line, which converts each argument
940 # into object IDs. From there it's a simple matter of detecting when
941 # the object IDs changed.
943 # In addition to object IDs, we also need to know when the set of
944 # named references (branches, tags) changes so that an update is
945 # triggered when new branches and tags are created.
946 refs = set(model.local_branches + model.remote_branches + model.tags)
947 argv = utils.shell_split(ref or 'HEAD')
948 oids = gitcmds.parse_refs(context, argv)
949 update = (
950 self.force_refresh
951 or count != self.old_count
952 or oids != self.old_oids
953 or refs != self.old_refs
955 if update:
956 self.thread.stop()
957 self.params.set_ref(ref)
958 self.params.set_count(count)
959 self.thread.start()
961 self.old_oids = oids
962 self.old_count = count
963 self.old_refs = refs
965 def select_commits(self, commits):
966 self.selection = commits
968 def clear(self):
969 self.commits.clear()
970 self.commit_list = []
971 self.graphview.clear()
972 self.treewidget.clear()
974 def add_commits(self, commits):
975 self.commit_list.extend(commits)
976 # Keep track of commits
977 for commit_obj in commits:
978 self.commits[commit_obj.oid] = commit_obj
979 for tag in commit_obj.tags:
980 self.commits[tag] = commit_obj
981 self.graphview.add_commits(commits)
982 self.treewidget.add_commits(commits)
984 def thread_begin(self):
985 self.clear()
987 def thread_end(self):
988 self.restore_selection()
990 def thread_status(self, successful):
991 self.revtext.hint.set_error(not successful)
993 def restore_selection(self):
994 selection = self.selection
995 try:
996 commit_obj = self.commit_list[-1]
997 except IndexError:
998 # No commits, exist, early-out
999 return
1001 new_commits = [self.commits.get(s.oid, None) for s in selection]
1002 new_commits = [c for c in new_commits if c is not None]
1003 if new_commits:
1004 # The old selection exists in the new state
1005 self.commits_selected.emit(sort_by_generation(new_commits))
1006 else:
1007 # The old selection is now empty. Select the top-most commit
1008 self.commits_selected.emit([commit_obj])
1010 self.graphview.set_initial_view()
1012 def diff_commits(self, a, b):
1013 paths = self.params.paths()
1014 if paths:
1015 cmds.difftool_launch(self.context, left=a, right=b, paths=paths)
1016 else:
1017 difftool.diff_commits(self.context, self, a, b)
1019 # Qt overrides
1020 def closeEvent(self, event):
1021 self.revtext.close_popup()
1022 self.thread.stop()
1023 standard.MainWindow.closeEvent(self, event)
1025 def histories_selected(self, histories):
1026 argv = [self.model.currentbranch, '--']
1027 argv.extend(histories)
1028 text = core.list2cmdline(argv)
1029 self.revtext.setText(text)
1030 self.display()
1032 def difftool_selected(self, files):
1033 bottom, top = self.treewidget.selected_commit_range()
1034 if not top:
1035 return
1036 cmds.difftool_launch(
1037 self.context, left=bottom, left_take_parent=True, right=top, paths=files
1040 def grab_file(self, filename):
1041 """Save the selected file from the filelist widget"""
1042 oid = self.treewidget.selected_oid()
1043 model = browse.BrowseModel(oid, filename=filename)
1044 browse.save_path(self.context, filename, model)
1047 class ReaderThread(QtCore.QThread):
1048 begin = Signal()
1049 add = Signal(object)
1050 end = Signal()
1051 status = Signal(object)
1053 def __init__(self, context, params, parent):
1054 QtCore.QThread.__init__(self, parent)
1055 self.context = context
1056 self.params = params
1057 self._abort = False
1058 self._stop = False
1059 self._mutex = QtCore.QMutex()
1060 self._condition = QtCore.QWaitCondition()
1062 def run(self):
1063 context = self.context
1064 repo = dag.RepoReader(context, self.params)
1065 repo.reset()
1066 self.begin.emit()
1067 commits = []
1068 for c in repo.get():
1069 self._mutex.lock()
1070 if self._stop:
1071 self._condition.wait(self._mutex)
1072 self._mutex.unlock()
1073 if self._abort:
1074 repo.reset()
1075 return
1076 commits.append(c)
1077 if len(commits) >= 512:
1078 self.add.emit(commits)
1079 commits = []
1081 self.status.emit(repo.returncode == 0)
1082 if commits:
1083 self.add.emit(commits)
1084 self.end.emit()
1086 def start(self):
1087 self._abort = False
1088 self._stop = False
1089 QtCore.QThread.start(self)
1091 def pause(self):
1092 self._mutex.lock()
1093 self._stop = True
1094 self._mutex.unlock()
1096 def resume(self):
1097 self._mutex.lock()
1098 self._stop = False
1099 self._mutex.unlock()
1100 self._condition.wakeOne()
1102 def stop(self):
1103 self._abort = True
1104 self.wait()
1107 class Cache(object):
1109 _label_font = None
1111 @classmethod
1112 def label_font(cls):
1113 font = cls._label_font
1114 if font is None:
1115 font = cls._label_font = QtWidgets.QApplication.font()
1116 font.setPointSize(6)
1117 return font
1120 class Edge(QtWidgets.QGraphicsItem):
1121 item_type = qtutils.standard_item_type_value(1)
1123 def __init__(self, source, dest):
1125 QtWidgets.QGraphicsItem.__init__(self)
1127 self.setAcceptedMouseButtons(Qt.NoButton)
1128 self.source = source
1129 self.dest = dest
1130 self.commit = source.commit
1131 self.setZValue(-2)
1133 self.recompute_bound()
1134 self.path = None
1135 self.path_valid = False
1137 # Choose a new color for new branch edges
1138 if self.source.x() < self.dest.x():
1139 color = EdgeColor.cycle()
1140 line = Qt.SolidLine
1141 elif self.source.x() != self.dest.x():
1142 color = EdgeColor.current()
1143 line = Qt.SolidLine
1144 else:
1145 color = EdgeColor.current()
1146 line = Qt.SolidLine
1148 self.pen = QtGui.QPen(color, 4.0, line, Qt.SquareCap, Qt.RoundJoin)
1150 def recompute_bound(self):
1151 dest_pt = Commit.item_bbox.center()
1153 self.source_pt = self.mapFromItem(self.source, dest_pt)
1154 self.dest_pt = self.mapFromItem(self.dest, dest_pt)
1155 self.line = QtCore.QLineF(self.source_pt, self.dest_pt)
1157 width = self.dest_pt.x() - self.source_pt.x()
1158 height = self.dest_pt.y() - self.source_pt.y()
1159 rect = QtCore.QRectF(self.source_pt, QtCore.QSizeF(width, height))
1160 self.bound = rect.normalized()
1162 def commits_were_invalidated(self):
1163 self.recompute_bound()
1164 self.prepareGeometryChange()
1165 # The path should not be recomputed immediately because just small part
1166 # of DAG is actually shown at same time. It will be recomputed on
1167 # demand in course of 'paint' method.
1168 self.path_valid = False
1169 # Hence, just queue redrawing.
1170 self.update()
1172 # Qt overrides
1173 def type(self):
1174 return self.item_type
1176 def boundingRect(self):
1177 return self.bound
1179 def recompute_path(self):
1180 QRectF = QtCore.QRectF
1181 QPointF = QtCore.QPointF
1183 arc_rect = 10
1184 connector_length = 5
1186 path = QtGui.QPainterPath()
1188 if self.source.x() == self.dest.x():
1189 path.moveTo(self.source.x(), self.source.y())
1190 path.lineTo(self.dest.x(), self.dest.y())
1191 else:
1192 # Define points starting from source
1193 point1 = QPointF(self.source.x(), self.source.y())
1194 point2 = QPointF(point1.x(), point1.y() - connector_length)
1195 point3 = QPointF(point2.x() + arc_rect, point2.y() - arc_rect)
1197 # Define points starting from dest
1198 point4 = QPointF(self.dest.x(), self.dest.y())
1199 point5 = QPointF(point4.x(), point3.y() - arc_rect)
1200 point6 = QPointF(point5.x() - arc_rect, point5.y() + arc_rect)
1202 start_angle_arc1 = 180
1203 span_angle_arc1 = 90
1204 start_angle_arc2 = 90
1205 span_angle_arc2 = -90
1207 # If the dest is at the left of the source, then we
1208 # need to reverse some values
1209 if self.source.x() > self.dest.x():
1210 point3 = QPointF(point2.x() - arc_rect, point3.y())
1211 point6 = QPointF(point5.x() + arc_rect, point6.y())
1213 span_angle_arc1 = 90
1215 path.moveTo(point1)
1216 path.lineTo(point2)
1217 path.arcTo(QRectF(point2, point3), start_angle_arc1, span_angle_arc1)
1218 path.lineTo(point6)
1219 path.arcTo(QRectF(point6, point5), start_angle_arc2, span_angle_arc2)
1220 path.lineTo(point4)
1222 self.path = path
1223 self.path_valid = True
1225 def paint(self, painter, _option, _widget):
1226 if not self.path_valid:
1227 self.recompute_path()
1228 painter.setPen(self.pen)
1229 painter.drawPath(self.path)
1232 class EdgeColor(object):
1233 """An edge color factory"""
1235 current_color_index = 0
1236 colors = [
1237 QtGui.QColor(Qt.red),
1238 QtGui.QColor(Qt.cyan),
1239 QtGui.QColor(Qt.magenta),
1240 QtGui.QColor(Qt.green),
1241 # Orange; Qt.yellow is too low-contrast
1242 qtutils.rgba(0xFF, 0x66, 0x00),
1245 @classmethod
1246 def update_colors(cls, theme):
1247 """Update the colors based on the color theme"""
1248 if theme.is_dark or theme.is_palette_dark:
1249 cls.colors.extend([
1250 QtGui.QColor(Qt.red).lighter(),
1251 QtGui.QColor(Qt.cyan).lighter(),
1252 QtGui.QColor(Qt.magenta).lighter(),
1253 QtGui.QColor(Qt.green).lighter(),
1254 QtGui.QColor(Qt.yellow).lighter(),
1256 else:
1257 cls.colors.extend([
1258 QtGui.QColor(Qt.blue),
1259 QtGui.QColor(Qt.darkRed),
1260 QtGui.QColor(Qt.darkCyan),
1261 QtGui.QColor(Qt.darkMagenta),
1262 QtGui.QColor(Qt.darkGreen),
1263 QtGui.QColor(Qt.darkYellow),
1264 QtGui.QColor(Qt.darkBlue),
1267 @classmethod
1268 def cycle(cls):
1269 cls.current_color_index += 1
1270 cls.current_color_index %= len(cls.colors)
1271 color = cls.colors[cls.current_color_index]
1272 color.setAlpha(128)
1273 return color
1275 @classmethod
1276 def current(cls):
1277 return cls.colors[cls.current_color_index]
1279 @classmethod
1280 def reset(cls):
1281 cls.current_color_index = 0
1284 class Commit(QtWidgets.QGraphicsItem):
1285 item_type = qtutils.standard_item_type_value(2)
1286 commit_radius = 12.0
1287 merge_radius = 18.0
1289 item_shape = QtGui.QPainterPath()
1290 item_shape.addRect(
1291 commit_radius / -2.0, commit_radius / -2.0, commit_radius, commit_radius
1293 item_bbox = item_shape.boundingRect()
1295 inner_rect = QtGui.QPainterPath()
1296 inner_rect.addRect(
1297 commit_radius / -2.0 + 2.0,
1298 commit_radius / -2.0 + 2.0,
1299 commit_radius - 4.0,
1300 commit_radius - 4.0,
1302 inner_rect = inner_rect.boundingRect()
1304 commit_color = QtGui.QColor(Qt.white)
1305 outline_color = commit_color.darker()
1306 merge_color = QtGui.QColor(Qt.lightGray)
1308 commit_selected_color = QtGui.QColor(Qt.green)
1309 selected_outline_color = commit_selected_color.darker()
1311 commit_pen = QtGui.QPen()
1312 commit_pen.setWidth(1)
1313 commit_pen.setColor(outline_color)
1315 def __init__(
1316 self,
1317 commit,
1318 selectable=QtWidgets.QGraphicsItem.ItemIsSelectable,
1319 cursor=Qt.PointingHandCursor,
1320 xpos=commit_radius / 2.0 + 1.0,
1321 cached_commit_color=commit_color,
1322 cached_merge_color=merge_color,
1324 QtWidgets.QGraphicsItem.__init__(self)
1326 self.commit = commit
1327 self.selected = False
1329 self.setZValue(0)
1330 self.setFlag(selectable)
1331 self.setCursor(cursor)
1332 self.setToolTip(commit.oid[:12] + ': ' + commit.summary)
1334 if commit.tags:
1335 self.label = label = Label(commit)
1336 label.setParentItem(self)
1337 label.setPos(xpos + 1, -self.commit_radius / 2.0)
1338 else:
1339 self.label = None
1341 if len(commit.parents) > 1:
1342 self.brush = cached_merge_color
1343 else:
1344 self.brush = cached_commit_color
1346 self.pressed = False
1347 self.dragged = False
1348 self.edges = {}
1350 def itemChange(self, change, value):
1351 if change == QtWidgets.QGraphicsItem.ItemSelectedHasChanged:
1352 # Cache the pen for use in paint()
1353 if value:
1354 self.brush = self.commit_selected_color
1355 color = self.selected_outline_color
1356 else:
1357 if len(self.commit.parents) > 1:
1358 self.brush = self.merge_color
1359 else:
1360 self.brush = self.commit_color
1361 color = self.outline_color
1362 commit_pen = QtGui.QPen()
1363 commit_pen.setWidth(1)
1364 commit_pen.setColor(color)
1365 self.commit_pen = commit_pen
1367 return QtWidgets.QGraphicsItem.itemChange(self, change, value)
1369 def type(self):
1370 return self.item_type
1372 def boundingRect(self):
1373 return self.item_bbox
1375 def shape(self):
1376 return self.item_shape
1378 def paint(self, painter, option, _widget):
1380 # Do not draw outside the exposed rect
1381 painter.setClipRect(option.exposedRect)
1383 # Draw ellipse
1384 painter.setPen(self.commit_pen)
1385 painter.setBrush(self.brush)
1386 painter.drawEllipse(self.inner_rect)
1388 def mousePressEvent(self, event):
1389 QtWidgets.QGraphicsItem.mousePressEvent(self, event)
1390 self.pressed = True
1391 self.selected = self.isSelected()
1393 def mouseMoveEvent(self, event):
1394 if self.pressed:
1395 self.dragged = True
1396 QtWidgets.QGraphicsItem.mouseMoveEvent(self, event)
1398 def mouseReleaseEvent(self, event):
1399 QtWidgets.QGraphicsItem.mouseReleaseEvent(self, event)
1400 if not self.dragged and self.selected and event.button() == Qt.LeftButton:
1401 return
1402 self.pressed = False
1403 self.dragged = False
1406 class Label(QtWidgets.QGraphicsItem):
1408 item_type = qtutils.graphics_item_type_value(3)
1410 head_color = QtGui.QColor(Qt.green)
1411 other_color = QtGui.QColor(Qt.white)
1412 remote_color = QtGui.QColor(Qt.yellow)
1414 head_pen = QtGui.QPen()
1415 head_pen.setColor(head_color.darker().darker())
1416 head_pen.setWidth(1)
1418 text_pen = QtGui.QPen()
1419 text_pen.setColor(QtGui.QColor(Qt.black))
1420 text_pen.setWidth(1)
1422 alpha = 200
1423 head_color.setAlpha(alpha)
1424 other_color.setAlpha(alpha)
1425 remote_color.setAlpha(alpha)
1427 border = 2
1428 item_spacing = 5
1429 text_offset = 1
1431 def __init__(self, commit):
1432 QtWidgets.QGraphicsItem.__init__(self)
1433 self.setZValue(-1)
1434 self.commit = commit
1436 def type(self):
1437 return self.item_type
1439 def boundingRect(self, cache=Cache):
1440 QPainterPath = QtGui.QPainterPath
1441 QRectF = QtCore.QRectF
1443 width = 72
1444 height = 18
1445 current_width = 0
1446 spacing = self.item_spacing
1447 border = self.border + self.text_offset # text offset=1 in paint()
1449 font = cache.label_font()
1450 item_shape = QPainterPath()
1452 base_rect = QRectF(0, 0, width, height)
1453 base_rect = base_rect.adjusted(-border, -border, border, border)
1454 item_shape.addRect(base_rect)
1456 for tag in self.commit.tags:
1457 text_shape = QPainterPath()
1458 text_shape.addText(current_width, 0, font, tag)
1459 text_rect = text_shape.boundingRect()
1460 box_rect = text_rect.adjusted(-border, -border, border, border)
1461 item_shape.addRect(box_rect)
1462 current_width = item_shape.boundingRect().width() + spacing
1464 return item_shape.boundingRect()
1466 def paint(self, painter, _option, _widget, cache=Cache):
1467 # Draw tags and branches
1468 font = cache.label_font()
1469 painter.setFont(font)
1471 current_width = 0
1472 border = self.border
1473 offset = self.text_offset
1474 spacing = self.item_spacing
1475 QRectF = QtCore.QRectF
1477 HEAD = 'HEAD'
1478 remotes_prefix = 'remotes/'
1479 tags_prefix = 'tags/'
1480 heads_prefix = 'heads/'
1481 remotes_len = len(remotes_prefix)
1482 tags_len = len(tags_prefix)
1483 heads_len = len(heads_prefix)
1485 for tag in self.commit.tags:
1486 if tag == HEAD:
1487 painter.setPen(self.text_pen)
1488 painter.setBrush(self.remote_color)
1489 elif tag.startswith(remotes_prefix):
1490 tag = tag[remotes_len:]
1491 painter.setPen(self.text_pen)
1492 painter.setBrush(self.other_color)
1493 elif tag.startswith(tags_prefix):
1494 tag = tag[tags_len:]
1495 painter.setPen(self.text_pen)
1496 painter.setBrush(self.remote_color)
1497 elif tag.startswith(heads_prefix):
1498 tag = tag[heads_len:]
1499 painter.setPen(self.head_pen)
1500 painter.setBrush(self.head_color)
1501 else:
1502 painter.setPen(self.text_pen)
1503 painter.setBrush(self.other_color)
1505 text_rect = painter.boundingRect(
1506 QRectF(current_width, 0, 0, 0), Qt.TextSingleLine, tag
1508 box_rect = text_rect.adjusted(-offset, -offset, offset, offset)
1510 painter.drawRoundedRect(box_rect, border, border)
1511 painter.drawText(text_rect, Qt.TextSingleLine, tag)
1512 current_width += text_rect.width() + spacing
1515 # pylint: disable=too-many-ancestors
1516 class GraphView(QtWidgets.QGraphicsView, ViewerMixin):
1518 commits_selected = Signal(object)
1519 diff_commits = Signal(object, object)
1521 x_adjust = int(Commit.commit_radius * 4 / 3)
1522 y_adjust = int(Commit.commit_radius * 4 / 3)
1524 x_off = -18
1525 y_off = -24
1527 def __init__(self, context, parent):
1528 QtWidgets.QGraphicsView.__init__(self, parent)
1529 ViewerMixin.__init__(self)
1530 EdgeColor.update_colors(context.app.theme)
1532 theme = context.app.theme
1533 highlight = theme.selection_color()
1534 Commit.commit_selected_color = highlight
1535 Commit.selected_outline_color = highlight.darker()
1537 self.context = context
1538 self.columns = {}
1539 self.menu_actions = None
1540 self.commits = []
1541 self.items = {}
1542 self.mouse_start = [0, 0]
1543 self.saved_matrix = self.transform()
1544 self.max_column = 0
1545 self.min_column = 0
1546 self.frontier = {}
1547 self.tagged_cells = set()
1549 self.x_start = 24
1550 self.x_min = 24
1551 self.x_offsets = collections.defaultdict(lambda: self.x_min)
1553 self.is_panning = False
1554 self.pressed = False
1555 self.selecting = False
1556 self.last_mouse = [0, 0]
1557 self.zoom = 2
1558 self.setDragMode(self.RubberBandDrag)
1560 scene = QtWidgets.QGraphicsScene(self)
1561 scene.setItemIndexMethod(QtWidgets.QGraphicsScene.BspTreeIndex)
1562 self.setScene(scene)
1564 # pylint: disable=no-member
1565 scene.selectionChanged.connect(self.selection_changed)
1567 self.setRenderHint(QtGui.QPainter.Antialiasing)
1568 self.setViewportUpdateMode(self.SmartViewportUpdate)
1569 self.setCacheMode(QtWidgets.QGraphicsView.CacheBackground)
1570 self.setTransformationAnchor(QtWidgets.QGraphicsView.AnchorUnderMouse)
1571 self.setResizeAnchor(QtWidgets.QGraphicsView.NoAnchor)
1573 background_color = qtutils.css_color(context.app.theme.background_color_rgb())
1574 self.setBackgroundBrush(background_color)
1576 qtutils.add_action(
1577 self,
1578 N_('Zoom In'),
1579 self.zoom_in,
1580 hotkeys.ZOOM_IN,
1581 hotkeys.ZOOM_IN_SECONDARY,
1584 qtutils.add_action(self, N_('Zoom Out'), self.zoom_out, hotkeys.ZOOM_OUT)
1586 qtutils.add_action(self, N_('Zoom to Fit'), self.zoom_to_fit, hotkeys.FIT)
1588 qtutils.add_action(
1589 self, N_('Select Parent'), self._select_parent, hotkeys.MOVE_DOWN_TERTIARY
1592 qtutils.add_action(
1593 self,
1594 N_('Select Oldest Parent'),
1595 self._select_oldest_parent,
1596 hotkeys.MOVE_DOWN,
1599 qtutils.add_action(
1600 self, N_('Select Child'), self._select_child, hotkeys.MOVE_UP_TERTIARY
1603 qtutils.add_action(
1604 self, N_('Select Newest Child'), self._select_newest_child, hotkeys.MOVE_UP
1607 def clear(self):
1608 EdgeColor.reset()
1609 self.scene().clear()
1610 self.items.clear()
1611 self.x_offsets.clear()
1612 self.x_min = 24
1613 self.commits = []
1615 # ViewerMixin interface
1616 def selected_items(self):
1617 """Return the currently selected items"""
1618 return self.scene().selectedItems()
1620 def zoom_in(self):
1621 self.scale_view(1.5)
1623 def zoom_out(self):
1624 self.scale_view(1.0 / 1.5)
1626 def selection_changed(self):
1627 # Broadcast selection to other widgets
1628 selected_items = self.scene().selectedItems()
1629 commits = sort_by_generation([item.commit for item in selected_items])
1630 self.set_selecting(True)
1631 self.commits_selected.emit(commits)
1632 self.set_selecting(False)
1634 def select_commits(self, commits):
1635 if self.selecting:
1636 return
1637 with qtutils.BlockSignals(self.scene()):
1638 self.select([commit.oid for commit in commits])
1640 def select(self, oids):
1641 """Select the item for the oids"""
1642 self.scene().clearSelection()
1643 for oid in oids:
1644 try:
1645 item = self.items[oid]
1646 except KeyError:
1647 continue
1648 item.setSelected(True)
1649 item_rect = item.sceneTransform().mapRect(item.boundingRect())
1650 self.ensureVisible(item_rect)
1652 def _get_item_by_generation(self, commits, criteria_fn):
1653 """Return the item for the commit matching criteria"""
1654 if not commits:
1655 return None
1656 generation = None
1657 for commit in commits:
1658 if generation is None or criteria_fn(generation, commit.generation):
1659 oid = commit.oid
1660 generation = commit.generation
1661 try:
1662 return self.items[oid]
1663 except KeyError:
1664 return None
1666 def _oldest_item(self, commits):
1667 """Return the item for the commit with the oldest generation number"""
1668 return self._get_item_by_generation(commits, lambda a, b: a > b)
1670 def _newest_item(self, commits):
1671 """Return the item for the commit with the newest generation number"""
1672 return self._get_item_by_generation(commits, lambda a, b: a < b)
1674 def create_patch(self):
1675 items = self.selected_items()
1676 if not items:
1677 return
1678 context = self.context
1679 selected_commits = sort_by_generation([n.commit for n in items])
1680 oids = [c.oid for c in selected_commits]
1681 all_oids = [c.oid for c in self.commits]
1682 cmds.do(cmds.FormatPatch, context, oids, all_oids)
1684 def _select_parent(self):
1685 """Select the parent with the newest generation number"""
1686 selected_item = self.selected_item()
1687 if selected_item is None:
1688 return
1689 parent_item = self._newest_item(selected_item.commit.parents)
1690 if parent_item is None:
1691 return
1692 selected_item.setSelected(False)
1693 parent_item.setSelected(True)
1694 self.ensureVisible(parent_item.mapRectToScene(parent_item.boundingRect()))
1696 def _select_oldest_parent(self):
1697 """Select the parent with the oldest generation number"""
1698 selected_item = self.selected_item()
1699 if selected_item is None:
1700 return
1701 parent_item = self._oldest_item(selected_item.commit.parents)
1702 if parent_item is None:
1703 return
1704 selected_item.setSelected(False)
1705 parent_item.setSelected(True)
1706 scene_rect = parent_item.mapRectToScene(parent_item.boundingRect())
1707 self.ensureVisible(scene_rect)
1709 def _select_child(self):
1710 """Select the child with the oldest generation number"""
1711 selected_item = self.selected_item()
1712 if selected_item is None:
1713 return
1714 child_item = self._oldest_item(selected_item.commit.children)
1715 if child_item is None:
1716 return
1717 selected_item.setSelected(False)
1718 child_item.setSelected(True)
1719 scene_rect = child_item.mapRectToScene(child_item.boundingRect())
1720 self.ensureVisible(scene_rect)
1722 def _select_newest_child(self):
1723 """Select the Nth child with the newest generation number (N > 1)"""
1724 selected_item = self.selected_item()
1725 if selected_item is None:
1726 return
1727 if len(selected_item.commit.children) > 1:
1728 children = selected_item.commit.children[1:]
1729 else:
1730 children = selected_item.commit.children
1731 child_item = self._newest_item(children)
1732 if child_item is None:
1733 return
1734 selected_item.setSelected(False)
1735 child_item.setSelected(True)
1736 scene_rect = child_item.mapRectToScene(child_item.boundingRect())
1737 self.ensureVisible(scene_rect)
1739 def set_initial_view(self):
1740 items = []
1741 selected = self.selected_items()
1742 if selected:
1743 items.extend(selected)
1745 if not selected and self.commits:
1746 commit = self.commits[-1]
1747 items.append(self.items[commit.oid])
1749 self.setSceneRect(self.scene().itemsBoundingRect())
1750 self.fit_view_to_items(items)
1752 def zoom_to_fit(self):
1753 """Fit selected items into the viewport"""
1754 items = self.selected_items()
1755 self.fit_view_to_items(items)
1757 def fit_view_to_items(self, items):
1758 if not items:
1759 rect = self.scene().itemsBoundingRect()
1760 else:
1761 x_min = y_min = maxsize
1762 x_max = y_max = -maxsize
1764 for item in items:
1765 pos = item.pos()
1766 x = pos.x()
1767 y = pos.y()
1768 x_min = min(x_min, x)
1769 x_max = max(x_max, x)
1770 y_min = min(y_min, y)
1771 y_max = max(y_max, y)
1773 rect = QtCore.QRectF(x_min, y_min, abs(x_max - x_min), abs(y_max - y_min))
1775 x_adjust = abs(GraphView.x_adjust)
1776 y_adjust = abs(GraphView.y_adjust)
1778 count = max(2.0, 10.0 - len(items) / 2.0)
1779 y_offset = int(y_adjust * count)
1780 x_offset = int(x_adjust * count)
1781 rect.setX(rect.x() - x_offset // 2)
1782 rect.setY(rect.y() - y_adjust // 2)
1783 rect.setHeight(rect.height() + y_offset)
1784 rect.setWidth(rect.width() + x_offset)
1786 self.fitInView(rect, Qt.KeepAspectRatio)
1787 self.scene().invalidate()
1789 def handle_event(self, event_handler, event, update=True):
1790 event_handler(self, event)
1791 if update:
1792 self.update()
1794 def set_selecting(self, selecting):
1795 self.selecting = selecting
1797 def pan(self, event):
1798 pos = event.pos()
1799 dx = pos.x() - self.mouse_start[0]
1800 dy = pos.y() - self.mouse_start[1]
1802 if dx == 0 and dy == 0:
1803 return
1805 rect = QtCore.QRect(0, 0, abs(dx), abs(dy))
1806 delta = self.mapToScene(rect).boundingRect()
1808 tx = delta.width()
1809 if dx < 0.0:
1810 tx = -tx
1812 ty = delta.height()
1813 if dy < 0.0:
1814 ty = -ty
1816 matrix = self.transform()
1817 matrix.reset()
1818 matrix *= self.saved_matrix
1819 matrix.translate(tx, ty)
1821 self.setTransformationAnchor(QtWidgets.QGraphicsView.NoAnchor)
1822 self.setTransform(matrix)
1824 def wheel_zoom(self, event):
1825 """Handle mouse wheel zooming."""
1826 delta = qtcompat.wheel_delta(event)
1827 zoom = math.pow(2.0, delta / 512.0)
1828 factor = (
1829 self.transform()
1830 .scale(zoom, zoom)
1831 .mapRect(QtCore.QRectF(0.0, 0.0, 1.0, 1.0))
1832 .width()
1834 if factor < 0.014 or factor > 42.0:
1835 return
1836 self.setTransformationAnchor(QtWidgets.QGraphicsView.AnchorUnderMouse)
1837 self.zoom = zoom
1838 self.scale(zoom, zoom)
1840 def wheel_pan(self, event):
1841 """Handle mouse wheel panning."""
1842 unit = QtCore.QRectF(0.0, 0.0, 1.0, 1.0)
1843 factor = 1.0 / self.transform().mapRect(unit).width()
1844 tx, ty = qtcompat.wheel_translation(event)
1846 matrix = self.transform().translate(tx * factor, ty * factor)
1847 self.setTransformationAnchor(QtWidgets.QGraphicsView.NoAnchor)
1848 self.setTransform(matrix)
1850 def scale_view(self, scale):
1851 factor = (
1852 self.transform()
1853 .scale(scale, scale)
1854 .mapRect(QtCore.QRectF(0, 0, 1, 1))
1855 .width()
1857 if factor < 0.07 or factor > 100.0:
1858 return
1859 self.zoom = scale
1861 adjust_scrollbars = True
1862 scrollbar = self.verticalScrollBar()
1863 if scrollbar:
1864 value = get(scrollbar)
1865 min_ = scrollbar.minimum()
1866 max_ = scrollbar.maximum()
1867 range_ = max_ - min_
1868 distance = value - min_
1869 nonzero_range = range_ > 0.1
1870 if nonzero_range:
1871 scrolloffset = distance / range_
1872 else:
1873 adjust_scrollbars = False
1875 self.setTransformationAnchor(QtWidgets.QGraphicsView.NoAnchor)
1876 self.scale(scale, scale)
1878 scrollbar = self.verticalScrollBar()
1879 if scrollbar and adjust_scrollbars:
1880 min_ = scrollbar.minimum()
1881 max_ = scrollbar.maximum()
1882 range_ = max_ - min_
1883 value = min_ + int(float(range_) * scrolloffset)
1884 scrollbar.setValue(value)
1886 def add_commits(self, commits):
1887 """Traverse commits and add them to the view."""
1888 self.commits.extend(commits)
1889 scene = self.scene()
1890 for commit in commits:
1891 item = Commit(commit)
1892 self.items[commit.oid] = item
1893 for ref in commit.tags:
1894 self.items[ref] = item
1895 scene.addItem(item)
1897 self.layout_commits()
1898 self.link(commits)
1900 def link(self, commits):
1901 """Create edges linking commits with their parents"""
1902 scene = self.scene()
1903 for commit in commits:
1904 try:
1905 commit_item = self.items[commit.oid]
1906 except KeyError:
1907 # TODO - Handle truncated history viewing
1908 continue
1909 for parent in reversed(commit.parents):
1910 try:
1911 parent_item = self.items[parent.oid]
1912 except KeyError:
1913 # TODO - Handle truncated history viewing
1914 continue
1915 try:
1916 edge = parent_item.edges[commit.oid]
1917 except KeyError:
1918 edge = Edge(parent_item, commit_item)
1919 else:
1920 continue
1921 parent_item.edges[commit.oid] = edge
1922 commit_item.edges[parent.oid] = edge
1923 scene.addItem(edge)
1925 def layout_commits(self):
1926 positions = self.position_nodes()
1928 # Each edge is accounted in two commits. Hence, accumulate invalid
1929 # edges to prevent double edge invalidation.
1930 invalid_edges = set()
1932 for oid, (x, y) in positions.items():
1933 item = self.items[oid]
1935 pos = item.pos()
1936 if pos != (x, y):
1937 item.setPos(x, y)
1939 for edge in item.edges.values():
1940 invalid_edges.add(edge)
1942 for edge in invalid_edges:
1943 edge.commits_were_invalidated()
1945 # Commit node layout technique
1947 # Nodes are aligned by a mesh. Columns and rows are distributed using
1948 # algorithms described below.
1950 # Row assignment algorithm
1952 # The algorithm aims consequent.
1953 # 1. A commit should be above all its parents.
1954 # 2. No commit should be at right side of a commit with a tag in same row.
1955 # This prevents overlapping of tag labels with commits and other labels.
1956 # 3. Commit density should be maximized.
1958 # The algorithm requires that all parents of a commit were assigned column.
1959 # Nodes must be traversed in generation ascend order. This guarantees that all
1960 # parents of a commit were assigned row. So, the algorithm may operate in
1961 # course of column assignment algorithm.
1963 # Row assignment uses frontier. A frontier is a dictionary that contains
1964 # minimum available row index for each column. It propagates during the
1965 # algorithm. Set of cells with tags is also maintained to meet second aim.
1967 # Initialization is performed by reset_rows method. Each new column should
1968 # be declared using declare_column method. Getting row for a cell is
1969 # implemented in alloc_cell method. Frontier must be propagated for any child
1970 # of fork commit which occupies different column. This meets first aim.
1972 # Column assignment algorithm
1974 # The algorithm traverses nodes in generation ascend order. This guarantees
1975 # that a node will be visited after all its parents.
1977 # The set of occupied columns are maintained during work. Initially it is
1978 # empty and no node occupied a column. Empty columns are allocated on demand.
1979 # Free index for column being allocated is searched in following way.
1980 # 1. Start from desired column and look towards graph center (0 column).
1981 # 2. Start from center and look in both directions simultaneously.
1982 # Desired column is defaulted to 0. Fork node should set desired column for
1983 # children equal to its one. This prevents branch from jumping too far from
1984 # its fork.
1986 # Initialization is performed by reset_columns method. Column allocation is
1987 # implemented in alloc_column method. Initialization and main loop are in
1988 # recompute_grid method. The method also embeds row assignment algorithm by
1989 # implementation.
1991 # Actions for each node are follow.
1992 # 1. If the node was not assigned a column then it is assigned empty one.
1993 # 2. Allocate row.
1994 # 3. Allocate columns for children.
1995 # If a child have a column assigned then it should no be overridden. One of
1996 # children is assigned same column as the node. If the node is a fork then the
1997 # child is chosen in generation descent order. This is a heuristic and it only
1998 # affects resulting appearance of the graph. Other children are assigned empty
1999 # columns in same order. It is the heuristic too.
2000 # 4. If no child occupies column of the node then leave it.
2001 # It is possible in consequent situations.
2002 # 4.1 The node is a leaf.
2003 # 4.2 The node is a fork and all its children are already assigned side
2004 # column. It is possible if all the children are merges.
2005 # 4.3 Single node child is a merge that is already assigned a column.
2006 # 5. Propagate frontier with respect to this node.
2007 # Each frontier entry corresponding to column occupied by any node's child
2008 # must be gather than node row index. This meets first aim of the row
2009 # assignment algorithm.
2010 # Note that frontier of child that occupies same row was propagated during
2011 # step 2. Hence, it must be propagated for children on side columns.
2013 def reset_columns(self):
2014 # Some children of displayed commits might not be accounted in
2015 # 'commits' list. It is common case during loading of big graph.
2016 # But, they are assigned a column that must be reseted. Hence, use
2017 # depth-first traversal to reset all columns assigned.
2018 for node in self.commits:
2019 if node.column is None:
2020 continue
2021 stack = [node]
2022 while stack:
2023 node = stack.pop()
2024 node.column = None
2025 for child in node.children:
2026 if child.column is not None:
2027 stack.append(child)
2029 self.columns = {}
2030 self.max_column = 0
2031 self.min_column = 0
2033 def reset_rows(self):
2034 self.frontier = {}
2035 self.tagged_cells = set()
2037 def declare_column(self, column):
2038 if self.frontier:
2039 # Align new column frontier by frontier of nearest column. If all
2040 # columns were left then select maximum frontier value.
2041 if not self.columns:
2042 self.frontier[column] = max(list(self.frontier.values()))
2043 return
2044 # This is heuristic that mostly affects roots. Note that the
2045 # frontier values for fork children will be overridden in course of
2046 # propagate_frontier.
2047 for offset in itertools.count(1):
2048 for c in [column + offset, column - offset]:
2049 if c not in self.columns:
2050 # Column 'c' is not occupied.
2051 continue
2052 try:
2053 frontier = self.frontier[c]
2054 except KeyError:
2055 # Column 'c' was never allocated.
2056 continue
2058 frontier -= 1
2059 # The frontier of the column may be higher because of
2060 # tag overlapping prevention performed for previous head.
2061 try:
2062 if self.frontier[column] >= frontier:
2063 break
2064 except KeyError:
2065 pass
2067 self.frontier[column] = frontier
2068 break
2069 else:
2070 continue
2071 break
2072 else:
2073 # First commit must be assigned 0 row.
2074 self.frontier[column] = 0
2076 def alloc_column(self, column=0):
2077 columns = self.columns
2078 # First, look for free column by moving from desired column to graph
2079 # center (column 0).
2080 for c in range(column, 0, -1 if column > 0 else 1):
2081 if c not in columns:
2082 if c > self.max_column:
2083 self.max_column = c
2084 elif c < self.min_column:
2085 self.min_column = c
2086 break
2087 else:
2088 # If no free column was found between graph center and desired
2089 # column then look for free one by moving from center along both
2090 # directions simultaneously.
2091 for c in itertools.count(0):
2092 if c not in columns:
2093 if c > self.max_column:
2094 self.max_column = c
2095 break
2096 c = -c
2097 if c not in columns:
2098 if c < self.min_column:
2099 self.min_column = c
2100 break
2101 self.declare_column(c)
2102 columns[c] = 1
2103 return c
2105 def alloc_cell(self, column, tags):
2106 # Get empty cell from frontier.
2107 cell_row = self.frontier[column]
2109 if tags:
2110 # Prevent overlapping of tag with cells already allocated a row.
2111 if self.x_off > 0:
2112 can_overlap = list(range(column + 1, self.max_column + 1))
2113 else:
2114 can_overlap = list(range(column - 1, self.min_column - 1, -1))
2115 for c in can_overlap:
2116 frontier = self.frontier[c]
2117 if frontier > cell_row:
2118 cell_row = frontier
2120 # Avoid overlapping with tags of commits at cell_row.
2121 if self.x_off > 0:
2122 can_overlap = list(range(self.min_column, column))
2123 else:
2124 can_overlap = list(range(self.max_column, column, -1))
2125 for cell_row in itertools.count(cell_row):
2126 for c in can_overlap:
2127 if (c, cell_row) in self.tagged_cells:
2128 # Overlapping. Try next row.
2129 break
2130 else:
2131 # No overlapping was found.
2132 break
2133 # Note that all checks should be made for new cell_row value.
2135 if tags:
2136 self.tagged_cells.add((column, cell_row))
2138 # Propagate frontier.
2139 self.frontier[column] = cell_row + 1
2140 return cell_row
2142 def propagate_frontier(self, column, value):
2143 current = self.frontier[column]
2144 if current < value:
2145 self.frontier[column] = value
2147 def leave_column(self, column):
2148 count = self.columns[column]
2149 if count == 1:
2150 del self.columns[column]
2151 else:
2152 self.columns[column] = count - 1
2154 def recompute_grid(self):
2155 self.reset_columns()
2156 self.reset_rows()
2158 for node in sort_by_generation(list(self.commits)):
2159 if node.column is None:
2160 # Node is either root or its parent is not in items. The last
2161 # happens when tree loading is in progress. Allocate new
2162 # columns for such nodes.
2163 node.column = self.alloc_column()
2165 node.row = self.alloc_cell(node.column, node.tags)
2167 # Allocate columns for children which are still without one. Also
2168 # propagate frontier for children.
2169 if node.is_fork():
2170 sorted_children = sorted(
2171 node.children, key=lambda c: c.generation, reverse=True
2173 citer = iter(sorted_children)
2174 for child in citer:
2175 if child.column is None:
2176 # Top most child occupies column of parent.
2177 child.column = node.column
2178 # Note that frontier is propagated in course of
2179 # alloc_cell.
2180 break
2181 self.propagate_frontier(child.column, node.row + 1)
2182 else:
2183 # No child occupies same column.
2184 self.leave_column(node.column)
2185 # Note that the loop below will pass no iteration.
2187 # Rest children are allocated new column.
2188 for child in citer:
2189 if child.column is None:
2190 child.column = self.alloc_column(node.column)
2191 self.propagate_frontier(child.column, node.row + 1)
2192 elif node.children:
2193 child = node.children[0]
2194 if child.column is None:
2195 child.column = node.column
2196 # Note that frontier is propagated in course of alloc_cell.
2197 elif child.column != node.column:
2198 # Child node have other parents and occupies column of one
2199 # of them.
2200 self.leave_column(node.column)
2201 # But frontier must be propagated with respect to this
2202 # parent.
2203 self.propagate_frontier(child.column, node.row + 1)
2204 else:
2205 # This is a leaf node.
2206 self.leave_column(node.column)
2208 def position_nodes(self):
2209 self.recompute_grid()
2211 x_start = self.x_start
2212 x_min = self.x_min
2213 x_off = self.x_off
2214 y_off = self.y_off
2216 positions = {}
2218 for node in self.commits:
2219 x_pos = x_start + node.column * x_off
2220 y_pos = y_off + node.row * y_off
2222 positions[node.oid] = (x_pos, y_pos)
2223 x_min = min(x_min, x_pos)
2225 self.x_min = x_min
2227 return positions
2229 # Qt overrides
2230 def contextMenuEvent(self, event):
2231 self.context_menu_event(event)
2233 def mousePressEvent(self, event):
2234 if event.button() == Qt.MidButton:
2235 pos = event.pos()
2236 self.mouse_start = [pos.x(), pos.y()]
2237 self.saved_matrix = self.transform()
2238 self.is_panning = True
2239 return
2240 if event.button() == Qt.RightButton:
2241 event.ignore()
2242 return
2243 if event.button() == Qt.LeftButton:
2244 self.pressed = True
2245 self.handle_event(QtWidgets.QGraphicsView.mousePressEvent, event)
2247 def mouseMoveEvent(self, event):
2248 if self.is_panning:
2249 self.pan(event)
2250 return
2251 pos = self.mapToScene(event.pos())
2252 self.last_mouse[0] = pos.x()
2253 self.last_mouse[1] = pos.y()
2254 self.handle_event(QtWidgets.QGraphicsView.mouseMoveEvent, event, update=False)
2256 def mouseReleaseEvent(self, event):
2257 self.pressed = False
2258 if event.button() == Qt.MidButton:
2259 self.is_panning = False
2260 return
2261 self.handle_event(QtWidgets.QGraphicsView.mouseReleaseEvent, event)
2262 self.viewport().repaint()
2264 def wheelEvent(self, event):
2265 """Handle Qt mouse wheel events."""
2266 if event.modifiers() & Qt.ControlModifier:
2267 self.wheel_zoom(event)
2268 else:
2269 self.wheel_pan(event)
2271 def fitInView(self, rect, flags=Qt.IgnoreAspectRatio):
2272 """Override fitInView to remove unwanted margins
2274 https://bugreports.qt.io/browse/QTBUG-42331 - based on QT sources
2277 if self.scene() is None or rect.isNull():
2278 return
2279 unity = self.transform().mapRect(QtCore.QRectF(0, 0, 1, 1))
2280 self.scale(1.0 / unity.width(), 1.0 / unity.height())
2281 view_rect = self.viewport().rect()
2282 scene_rect = self.transform().mapRect(rect)
2283 xratio = view_rect.width() / scene_rect.width()
2284 yratio = view_rect.height() / scene_rect.height()
2285 if flags == Qt.KeepAspectRatio:
2286 xratio = yratio = min(xratio, yratio)
2287 elif flags == Qt.KeepAspectRatioByExpanding:
2288 xratio = yratio = max(xratio, yratio)
2289 self.scale(xratio, yratio)
2290 self.centerOn(rect.center())
2293 def sort_by_generation(commits):
2294 if len(commits) < 2:
2295 return commits
2296 commits.sort(key=lambda x: x.generation)
2297 return commits
2300 # Glossary
2301 # ========
2302 # oid -- Git objects IDs (i.e. SHA-1 IDs)
2303 # ref -- Git references that resolve to a commit-ish (HEAD, branches, tags)