pylint: rename variables for readability
[git-cola.git] / cola / widgets / dag.py
blob0210a24428503d0e75cba70608b22e5f50707f9f
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 func = getattr(widget, name)
76 else:
77 func = getattr(self.default, name)
79 return func(*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, func):
118 """Run an operation with a commit object ID"""
119 oid = self.clicked_oid()
120 if oid:
121 result = func(oid)
122 else:
123 result = None
124 return result
126 def with_selected_oid(self, func):
127 """Run an operation with a commit object ID"""
128 oid = self.selected_oid()
129 if oid:
130 result = func(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(
292 has_single_selection_or_clicked
294 self.menu_actions['cherry_pick'].setEnabled(has_single_selection_or_clicked)
295 self.menu_actions['copy'].setEnabled(has_single_selection_or_clicked)
296 self.menu_actions['create_branch'].setEnabled(has_single_selection_or_clicked)
297 self.menu_actions['create_patch'].setEnabled(has_selection)
298 self.menu_actions['create_tag'].setEnabled(has_single_selection_or_clicked)
299 self.menu_actions['create_tarball'].setEnabled(has_single_selection_or_clicked)
300 self.menu_actions['rebase_to_commit'].setEnabled(
301 has_single_selection_or_clicked
303 self.menu_actions['reset_mixed'].setEnabled(has_single_selection_or_clicked)
304 self.menu_actions['reset_keep'].setEnabled(has_single_selection_or_clicked)
305 self.menu_actions['reset_merge'].setEnabled(has_single_selection_or_clicked)
306 self.menu_actions['reset_soft'].setEnabled(has_single_selection_or_clicked)
307 self.menu_actions['reset_hard'].setEnabled(has_single_selection_or_clicked)
308 self.menu_actions['restore_worktree'].setEnabled(
309 has_single_selection_or_clicked
311 self.menu_actions['revert'].setEnabled(has_single_selection_or_clicked)
312 self.menu_actions['save_blob'].setEnabled(has_single_selection_or_clicked)
314 def context_menu_event(self, event):
315 """Build a context menu and execute it"""
316 self.update_menu_actions(event)
317 menu = qtutils.create_menu(N_('Actions'), self)
318 menu.addAction(self.menu_actions['diff_this_selected'])
319 menu.addAction(self.menu_actions['diff_selected_this'])
320 menu.addAction(self.menu_actions['diff_commit'])
321 menu.addAction(self.menu_actions['diff_commit_all'])
322 menu.addSeparator()
323 menu.addAction(self.menu_actions['checkout_branch'])
324 menu.addAction(self.menu_actions['create_branch'])
325 menu.addAction(self.menu_actions['create_tag'])
326 menu.addAction(self.menu_actions['rebase_to_commit'])
327 menu.addSeparator()
328 menu.addAction(self.menu_actions['cherry_pick'])
329 menu.addAction(self.menu_actions['revert'])
330 menu.addAction(self.menu_actions['create_patch'])
331 menu.addAction(self.menu_actions['create_tarball'])
332 menu.addSeparator()
333 reset_menu = menu.addMenu(N_('Reset'))
334 reset_menu.addAction(self.menu_actions['reset_soft'])
335 reset_menu.addAction(self.menu_actions['reset_mixed'])
336 reset_menu.addAction(self.menu_actions['restore_worktree'])
337 reset_menu.addSeparator()
338 reset_menu.addAction(self.menu_actions['reset_keep'])
339 reset_menu.addAction(self.menu_actions['reset_merge'])
340 reset_menu.addAction(self.menu_actions['reset_hard'])
341 menu.addAction(self.menu_actions['checkout_detached'])
342 menu.addSeparator()
343 menu.addAction(self.menu_actions['save_blob'])
344 menu.addAction(self.menu_actions['copy'])
345 menu.exec_(self.mapToGlobal(event.pos()))
348 def set_icon(icon, action):
349 """ "Set the icon for an action and return the action"""
350 action.setIcon(icon)
351 return action
354 def viewer_actions(widget):
355 """Return commont actions across the tree and graph widgets"""
356 return {
357 'diff_this_selected': set_icon(
358 icons.compare(),
359 qtutils.add_action(
360 widget, N_('Diff this -> selected'), widget.proxy.diff_this_selected
363 'diff_selected_this': set_icon(
364 icons.compare(),
365 qtutils.add_action(
366 widget, N_('Diff selected -> this'), widget.proxy.diff_selected_this
369 'create_branch': set_icon(
370 icons.branch(),
371 qtutils.add_action(widget, N_('Create Branch'), widget.proxy.create_branch),
373 'create_patch': set_icon(
374 icons.save(),
375 qtutils.add_action(widget, N_('Create Patch'), widget.proxy.create_patch),
377 'create_tag': set_icon(
378 icons.tag(),
379 qtutils.add_action(widget, N_('Create Tag'), widget.proxy.create_tag),
381 'create_tarball': set_icon(
382 icons.file_zip(),
383 qtutils.add_action(
384 widget, N_('Save As Tarball/Zip...'), widget.proxy.create_tarball
387 'cherry_pick': set_icon(
388 icons.cherry_pick(),
389 qtutils.add_action(widget, N_('Cherry Pick'), widget.proxy.cherry_pick),
391 'revert': set_icon(
392 icons.undo(), qtutils.add_action(widget, N_('Revert'), widget.proxy.revert)
394 'diff_commit': set_icon(
395 icons.diff(),
396 qtutils.add_action(
397 widget, N_('Launch Diff Tool'), widget.proxy.show_diff, hotkeys.DIFF
400 'diff_commit_all': set_icon(
401 icons.diff(),
402 qtutils.add_action(
403 widget,
404 N_('Launch Directory Diff Tool'),
405 widget.proxy.show_dir_diff,
406 hotkeys.DIFF_SECONDARY,
409 'checkout_branch': set_icon(
410 icons.branch(),
411 qtutils.add_action(
412 widget, N_('Checkout Branch'), widget.proxy.checkout_branch
415 'checkout_detached': qtutils.add_action(
416 widget, N_('Checkout Detached HEAD'), widget.proxy.checkout_detached
418 'rebase_to_commit': set_icon(
419 icons.play(),
420 qtutils.add_action(
421 widget, N_('Rebase to this commit'), widget.proxy.rebase_to_commit
424 'reset_soft': set_icon(
425 icons.style_dialog_reset(),
426 qtutils.add_action(
427 widget, N_('Reset Branch (Soft)'), widget.proxy.reset_soft
430 'reset_mixed': set_icon(
431 icons.style_dialog_reset(),
432 qtutils.add_action(
433 widget, N_('Reset Branch and Stage (Mixed)'), widget.proxy.reset_mixed
436 'reset_keep': set_icon(
437 icons.style_dialog_reset(),
438 qtutils.add_action(
439 widget,
440 N_('Restore Worktree and Reset All (Keep Unstaged Edits)'),
441 widget.proxy.reset_keep,
444 'reset_merge': set_icon(
445 icons.style_dialog_reset(),
446 qtutils.add_action(
447 widget,
448 N_('Restore Worktree and Reset All (Merge)'),
449 widget.proxy.reset_merge,
452 'reset_hard': set_icon(
453 icons.style_dialog_reset(),
454 qtutils.add_action(
455 widget,
456 N_('Restore Worktree and Reset All (Hard)'),
457 widget.proxy.reset_hard,
460 'restore_worktree': set_icon(
461 icons.edit(),
462 qtutils.add_action(
463 widget, N_('Restore Worktree'), widget.proxy.restore_worktree
466 'save_blob': set_icon(
467 icons.save(),
468 qtutils.add_action(
469 widget, N_('Grab File...'), widget.proxy.save_blob_dialog
472 'copy': set_icon(
473 icons.copy(),
474 qtutils.add_action(
475 widget,
476 N_('Copy SHA-1'),
477 widget.proxy.copy_to_clipboard,
478 hotkeys.COPY_SHA1,
484 class CommitTreeWidgetItem(QtWidgets.QTreeWidgetItem):
485 """Custom TreeWidgetItem used in to build the commit tree widget"""
487 def __init__(self, commit, parent=None):
488 QtWidgets.QTreeWidgetItem.__init__(self, parent)
489 self.commit = commit
490 self.setText(0, commit.summary)
491 self.setText(1, commit.author)
492 self.setText(2, commit.authdate)
495 # pylint: disable=too-many-ancestors
496 class CommitTreeWidget(standard.TreeWidget, ViewerMixin):
497 """Display commits using a flat treewidget in "list" mode"""
499 commits_selected = Signal(object)
500 diff_commits = Signal(object, object)
501 zoom_to_fit = Signal()
503 def __init__(self, context, parent):
504 standard.TreeWidget.__init__(self, parent)
505 ViewerMixin.__init__(self)
507 self.setSelectionMode(self.ExtendedSelection)
508 self.setHeaderLabels([N_('Summary'), N_('Author'), N_('Date, Time')])
510 self.context = context
511 self.oidmap = {}
512 self.menu_actions = None
513 self.selecting = False
514 self.commits = []
515 self._adjust_columns = False
517 self.action_up = qtutils.add_action(
518 self, N_('Go Up'), self.go_up, hotkeys.MOVE_UP
521 self.action_down = qtutils.add_action(
522 self, N_('Go Down'), self.go_down, hotkeys.MOVE_DOWN
525 self.zoom_to_fit_action = qtutils.add_action(
526 self, N_('Zoom to Fit'), self.zoom_to_fit.emit, hotkeys.FIT
529 # pylint: disable=no-member
530 self.itemSelectionChanged.connect(self.selection_changed)
532 def export_state(self):
533 """Export the widget's state"""
534 # The base class method is intentionally overridden because we only
535 # care about the details below for this subwidget.
536 state = {}
537 state['column_widths'] = self.column_widths()
538 return state
540 def apply_state(self, state):
541 """Apply the exported widget state"""
542 try:
543 column_widths = state['column_widths']
544 except (KeyError, ValueError):
545 column_widths = None
546 if column_widths:
547 self.set_column_widths(column_widths)
548 else:
549 # Defer showing the columns until we are shown, and our true width
550 # is known. Calling adjust_columns() here ends up with the wrong
551 # answer because we have not yet been parented to the layout.
552 # We set this flag that we process once during our initial
553 # showEvent().
554 self._adjust_columns = True
555 return True
557 # Qt overrides
558 def showEvent(self, event):
559 """Override QWidget::showEvent() to size columns when we are shown"""
560 if self._adjust_columns:
561 self._adjust_columns = False
562 width = self.width()
563 two_thirds = (width * 2) // 3
564 one_sixth = width // 6
566 self.setColumnWidth(0, two_thirds)
567 self.setColumnWidth(1, one_sixth)
568 self.setColumnWidth(2, one_sixth)
569 return standard.TreeWidget.showEvent(self, event)
571 # ViewerMixin
572 def go_up(self):
573 """Select the item above the current item"""
574 self.goto(self.itemAbove)
576 def go_down(self):
577 """Select the item below the current item"""
578 self.goto(self.itemBelow)
580 def goto(self, finder):
581 """Move the selection using a finder strategy"""
582 items = self.selected_items()
583 item = items[0] if items else None
584 if item is None:
585 return
586 found = finder(item)
587 if found:
588 self.select([found.commit.oid])
590 def selected_commit_range(self):
591 """Return a range of selected commits"""
592 selected_items = self.selected_items()
593 if not selected_items:
594 return None, None
595 return selected_items[-1].commit.oid, selected_items[0].commit.oid
597 def set_selecting(self, selecting):
598 """Record the "are we selecting?" status"""
599 self.selecting = selecting
601 def selection_changed(self):
602 """Respond to itemSelectionChanged notifications"""
603 items = self.selected_items()
604 if not items:
605 self.set_selecting(True)
606 self.commits_selected.emit([])
607 self.set_selecting(False)
608 return
609 self.set_selecting(True)
610 self.commits_selected.emit(sort_by_generation([i.commit for i in items]))
611 self.set_selecting(False)
613 def select_commits(self, commits):
614 """Select commits that were selected by the sibling tree/graph widget"""
615 if self.selecting:
616 return
617 with qtutils.BlockSignals(self):
618 self.select([commit.oid for commit in commits])
620 def select(self, oids):
621 """Mark items as selected"""
622 self.clearSelection()
623 if not oids:
624 return
625 for oid in oids:
626 try:
627 item = self.oidmap[oid]
628 except KeyError:
629 continue
630 self.scrollToItem(item)
631 item.setSelected(True)
633 def clear(self):
634 """Clear the tree"""
635 QtWidgets.QTreeWidget.clear(self)
636 self.oidmap.clear()
637 self.commits = []
639 def add_commits(self, commits):
640 """Add commits to the tree"""
641 self.commits.extend(commits)
642 items = []
643 for c in reversed(commits):
644 item = CommitTreeWidgetItem(c)
645 items.append(item)
646 self.oidmap[c.oid] = item
647 for tag in c.tags:
648 self.oidmap[tag] = item
649 self.insertTopLevelItems(0, items)
651 def create_patch(self):
652 """Export a patch from the selected items"""
653 items = self.selectedItems()
654 if not items:
655 return
656 context = self.context
657 oids = [item.commit.oid for item in reversed(items)]
658 all_oids = [c.oid for c in self.commits]
659 cmds.do(cmds.FormatPatch, context, oids, all_oids)
661 # Qt overrides
662 def contextMenuEvent(self, event):
663 """Create a custom context menu and execute it"""
664 self.context_menu_event(event)
666 def mousePressEvent(self, event):
667 """Intercept the right-click event to retain selection state"""
668 item = self.itemAt(event.pos())
669 if item is None:
670 self.clicked = None
671 else:
672 self.clicked = item.commit
673 if event.button() == Qt.RightButton:
674 event.accept()
675 return
676 QtWidgets.QTreeWidget.mousePressEvent(self, event)
679 class GitDAG(standard.MainWindow):
680 """The git-dag widget."""
682 commits_selected = Signal(object)
684 def __init__(self, context, params, parent=None):
685 super(GitDAG, self).__init__(parent)
687 self.setMinimumSize(420, 420)
689 # change when widgets are added/removed
690 self.widget_version = 2
691 self.context = context
692 self.params = params
693 self.model = context.model
695 self.commits = {}
696 self.commit_list = []
697 self.selection = []
698 self.old_refs = set()
699 self.old_oids = None
700 self.old_count = 0
701 self.force_refresh = False
703 self.thread = None
704 self.revtext = completion.GitLogLineEdit(context)
705 self.maxresults = standard.SpinBox()
707 self.zoom_out = qtutils.create_action_button(
708 tooltip=N_('Zoom Out'), icon=icons.zoom_out()
711 self.zoom_in = qtutils.create_action_button(
712 tooltip=N_('Zoom In'), icon=icons.zoom_in()
715 self.zoom_to_fit = qtutils.create_action_button(
716 tooltip=N_('Zoom to Fit'), icon=icons.zoom_fit_best()
719 self.treewidget = CommitTreeWidget(context, self)
720 self.diffwidget = diff.DiffWidget(context, self, is_commit=True)
721 self.filewidget = filelist.FileWidget(context, self)
722 self.graphview = GraphView(context, self)
724 self.treewidget.commits_selected.connect(self.commits_selected)
725 self.graphview.commits_selected.connect(self.commits_selected)
727 self.commits_selected.connect(self.select_commits)
728 self.commits_selected.connect(self.diffwidget.commits_selected)
729 self.commits_selected.connect(self.filewidget.commits_selected)
730 self.commits_selected.connect(self.graphview.select_commits)
731 self.commits_selected.connect(self.treewidget.select_commits)
733 self.filewidget.files_selected.connect(self.diffwidget.files_selected)
734 self.filewidget.difftool_selected.connect(self.difftool_selected)
735 self.filewidget.histories_selected.connect(self.histories_selected)
737 self.proxy = FocusRedirectProxy(
738 self.treewidget, self.graphview, self.filewidget
741 self.viewer_actions = actions = viewer_actions(self)
742 self.treewidget.menu_actions = actions
743 self.graphview.menu_actions = actions
745 self.controls_layout = qtutils.hbox(
746 defs.no_margin, defs.spacing, self.revtext, self.maxresults
749 self.controls_widget = QtWidgets.QWidget()
750 self.controls_widget.setLayout(self.controls_layout)
752 self.log_dock = qtutils.create_dock('Log', N_('Log'), self, stretch=False)
753 self.log_dock.setWidget(self.treewidget)
754 log_dock_titlebar = self.log_dock.titleBarWidget()
755 log_dock_titlebar.add_corner_widget(self.controls_widget)
757 self.file_dock = qtutils.create_dock('Files', N_('Files'), self)
758 self.file_dock.setWidget(self.filewidget)
760 self.diff_options = diff.Options(self.diffwidget)
761 self.diffwidget.set_options(self.diff_options)
762 self.diff_options.hide_advanced_options()
763 self.diff_options.set_diff_type(main.Types.TEXT)
765 self.diff_dock = qtutils.create_dock('Diff', N_('Diff'), self)
766 self.diff_dock.setWidget(self.diffwidget)
768 diff_titlebar = self.diff_dock.titleBarWidget()
769 diff_titlebar.add_corner_widget(self.diff_options)
771 self.graph_controls_layout = qtutils.hbox(
772 defs.no_margin,
773 defs.button_spacing,
774 self.zoom_out,
775 self.zoom_in,
776 self.zoom_to_fit,
777 defs.spacing,
780 self.graph_controls_widget = QtWidgets.QWidget()
781 self.graph_controls_widget.setLayout(self.graph_controls_layout)
783 self.graphview_dock = qtutils.create_dock('Graph', N_('Graph'), self)
784 self.graphview_dock.setWidget(self.graphview)
785 graph_titlebar = self.graphview_dock.titleBarWidget()
786 graph_titlebar.add_corner_widget(self.graph_controls_widget)
788 self.lock_layout_action = qtutils.add_action_bool(
789 self, N_('Lock Layout'), self.set_lock_layout, False
792 self.refresh_action = qtutils.add_action(
793 self, N_('Refresh'), self.refresh, hotkeys.REFRESH
796 # Create the application menu
797 self.menubar = QtWidgets.QMenuBar(self)
798 self.setMenuBar(self.menubar)
800 # View Menu
801 self.view_menu = qtutils.add_menu(N_('View'), self.menubar)
802 self.view_menu.addAction(self.refresh_action)
803 self.view_menu.addAction(self.log_dock.toggleViewAction())
804 self.view_menu.addAction(self.graphview_dock.toggleViewAction())
805 self.view_menu.addAction(self.diff_dock.toggleViewAction())
806 self.view_menu.addAction(self.file_dock.toggleViewAction())
807 self.view_menu.addSeparator()
808 self.view_menu.addAction(self.lock_layout_action)
810 left = Qt.LeftDockWidgetArea
811 right = Qt.RightDockWidgetArea
812 self.addDockWidget(left, self.log_dock)
813 self.addDockWidget(left, self.diff_dock)
814 self.addDockWidget(right, self.graphview_dock)
815 self.addDockWidget(right, self.file_dock)
817 # Also re-loads dag.* from the saved state
818 self.init_state(context.settings, self.resize_to_desktop)
820 qtutils.connect_button(self.zoom_out, self.graphview.zoom_out)
821 qtutils.connect_button(self.zoom_in, self.graphview.zoom_in)
822 qtutils.connect_button(self.zoom_to_fit, self.graphview.zoom_to_fit)
824 self.treewidget.zoom_to_fit.connect(self.graphview.zoom_to_fit)
825 self.treewidget.diff_commits.connect(self.diff_commits)
826 self.graphview.diff_commits.connect(self.diff_commits)
827 self.filewidget.grab_file.connect(self.grab_file)
829 # pylint: disable=no-member
830 self.maxresults.editingFinished.connect(self.display)
832 self.revtext.textChanged.connect(self.text_changed)
833 self.revtext.activated.connect(self.display)
834 self.revtext.enter.connect(self.display)
835 self.revtext.down.connect(self.focus_tree)
837 # The model is updated in another thread so use
838 # signals/slots to bring control back to the main GUI thread
839 self.model.updated.connect(self.model_updated, type=Qt.QueuedConnection)
841 qtutils.add_action(self, 'FocusInput', self.focus_input, hotkeys.FOCUS_INPUT)
842 qtutils.add_action(self, 'FocusTree', self.focus_tree, hotkeys.FOCUS_TREE)
843 qtutils.add_action(self, 'FocusDiff', self.focus_diff, hotkeys.FOCUS_DIFF)
844 qtutils.add_close_action(self)
846 self.set_params(params)
848 def set_params(self, params):
849 context = self.context
850 self.params = params
852 # Update fields affected by model
853 self.revtext.setText(params.ref)
854 self.maxresults.setValue(params.count)
855 self.update_window_title()
857 if self.thread is not None:
858 self.thread.stop()
860 self.thread = ReaderThread(context, params, self)
862 thread = self.thread
863 thread.begin.connect(self.thread_begin, type=Qt.QueuedConnection)
864 thread.status.connect(self.thread_status, type=Qt.QueuedConnection)
865 thread.add.connect(self.add_commits, type=Qt.QueuedConnection)
866 thread.end.connect(self.thread_end, type=Qt.QueuedConnection)
868 def focus_input(self):
869 """Focus the revision input field"""
870 self.revtext.setFocus()
872 def focus_tree(self):
873 """Focus the revision tree list widget"""
874 self.treewidget.setFocus()
876 def focus_diff(self):
877 """Focus the diff widget"""
878 self.diffwidget.setFocus()
880 def text_changed(self, txt):
881 self.params.ref = txt
882 self.update_window_title()
884 def update_window_title(self):
885 project = self.model.project
886 if self.params.ref:
887 self.setWindowTitle(
888 N_('%(project)s: %(ref)s - DAG')
889 % dict(project=project, ref=self.params.ref)
891 else:
892 self.setWindowTitle(project + N_(' - DAG'))
894 def export_state(self):
895 state = standard.MainWindow.export_state(self)
896 state['count'] = self.params.count
897 state['log'] = self.treewidget.export_state()
898 state['word_wrap'] = self.diffwidget.options.enable_word_wrapping.isChecked()
899 return state
901 def apply_state(self, state):
902 result = standard.MainWindow.apply_state(self, state)
903 try:
904 count = state['count']
905 if self.params.overridden('count'):
906 count = self.params.count
907 except (KeyError, TypeError, ValueError, AttributeError):
908 count = self.params.count
909 result = False
910 self.params.set_count(count)
911 self.lock_layout_action.setChecked(state.get('lock_layout', False))
912 self.diffwidget.set_word_wrapping(state.get('word_wrap', False), update=True)
914 try:
915 log_state = state['log']
916 except (KeyError, ValueError):
917 log_state = None
918 if log_state:
919 self.treewidget.apply_state(log_state)
921 return result
923 def model_updated(self):
924 self.display()
925 self.update_window_title()
927 def refresh(self):
928 """Unconditionally refresh the DAG"""
929 # self.force_refresh triggers an Unconditional redraw
930 self.force_refresh = True
931 cmds.do(cmds.Refresh, self.context)
932 self.force_refresh = False
934 def display(self):
935 """Update the view when the Git refs change"""
936 ref = get(self.revtext)
937 count = get(self.maxresults)
938 context = self.context
939 model = self.model
940 # The DAG tries to avoid updating when the object IDs have not
941 # changed. Without doing this the DAG constantly redraws itself
942 # whenever inotify sends update events, which hurts usability.
944 # To minimize redraws we leverage `git rev-parse`. The strategy is to
945 # use `git rev-parse` on the input line, which converts each argument
946 # into object IDs. From there it's a simple matter of detecting when
947 # the object IDs changed.
949 # In addition to object IDs, we also need to know when the set of
950 # named references (branches, tags) changes so that an update is
951 # triggered when new branches and tags are created.
952 refs = set(model.local_branches + model.remote_branches + model.tags)
953 argv = utils.shell_split(ref or 'HEAD')
954 oids = gitcmds.parse_refs(context, argv)
955 update = (
956 self.force_refresh
957 or count != self.old_count
958 or oids != self.old_oids
959 or refs != self.old_refs
961 if update:
962 self.thread.stop()
963 self.params.set_ref(ref)
964 self.params.set_count(count)
965 self.thread.start()
967 self.old_oids = oids
968 self.old_count = count
969 self.old_refs = refs
971 def select_commits(self, commits):
972 self.selection = commits
974 def clear(self):
975 self.commits.clear()
976 self.commit_list = []
977 self.graphview.clear()
978 self.treewidget.clear()
980 def add_commits(self, commits):
981 self.commit_list.extend(commits)
982 # Keep track of commits
983 for commit_obj in commits:
984 self.commits[commit_obj.oid] = commit_obj
985 for tag in commit_obj.tags:
986 self.commits[tag] = commit_obj
987 self.graphview.add_commits(commits)
988 self.treewidget.add_commits(commits)
990 def thread_begin(self):
991 self.clear()
993 def thread_end(self):
994 self.restore_selection()
996 def thread_status(self, successful):
997 self.revtext.hint.set_error(not successful)
999 def restore_selection(self):
1000 selection = self.selection
1001 try:
1002 commit_obj = self.commit_list[-1]
1003 except IndexError:
1004 # No commits, exist, early-out
1005 return
1007 new_commits = [self.commits.get(s.oid, None) for s in selection]
1008 new_commits = [c for c in new_commits if c is not None]
1009 if new_commits:
1010 # The old selection exists in the new state
1011 self.commits_selected.emit(sort_by_generation(new_commits))
1012 else:
1013 # The old selection is now empty. Select the top-most commit
1014 self.commits_selected.emit([commit_obj])
1016 self.graphview.set_initial_view()
1018 def diff_commits(self, left, right):
1019 paths = self.params.paths()
1020 if paths:
1021 cmds.difftool_launch(self.context, left=left, right=right, paths=paths)
1022 else:
1023 difftool.diff_commits(self.context, self, left, right)
1025 # Qt overrides
1026 def closeEvent(self, event):
1027 self.revtext.close_popup()
1028 self.thread.stop()
1029 standard.MainWindow.closeEvent(self, event)
1031 def histories_selected(self, histories):
1032 argv = [self.model.currentbranch, '--']
1033 argv.extend(histories)
1034 text = core.list2cmdline(argv)
1035 self.revtext.setText(text)
1036 self.display()
1038 def difftool_selected(self, files):
1039 bottom, top = self.treewidget.selected_commit_range()
1040 if not top:
1041 return
1042 cmds.difftool_launch(
1043 self.context, left=bottom, left_take_parent=True, right=top, paths=files
1046 def grab_file(self, filename):
1047 """Save the selected file from the filelist widget"""
1048 oid = self.treewidget.selected_oid()
1049 model = browse.BrowseModel(oid, filename=filename)
1050 browse.save_path(self.context, filename, model)
1053 class ReaderThread(QtCore.QThread):
1054 begin = Signal()
1055 add = Signal(object)
1056 end = Signal()
1057 status = Signal(object)
1059 def __init__(self, context, params, parent):
1060 QtCore.QThread.__init__(self, parent)
1061 self.context = context
1062 self.params = params
1063 self._abort = False
1064 self._stop = False
1065 self._mutex = QtCore.QMutex()
1066 self._condition = QtCore.QWaitCondition()
1068 def run(self):
1069 context = self.context
1070 repo = dag.RepoReader(context, self.params)
1071 repo.reset()
1072 self.begin.emit()
1073 commits = []
1074 for commit in repo.get():
1075 self._mutex.lock()
1076 if self._stop:
1077 self._condition.wait(self._mutex)
1078 self._mutex.unlock()
1079 if self._abort:
1080 repo.reset()
1081 return
1082 commits.append(commit)
1083 if len(commits) >= 512:
1084 self.add.emit(commits)
1085 commits = []
1087 self.status.emit(repo.returncode == 0)
1088 if commits:
1089 self.add.emit(commits)
1090 self.end.emit()
1092 def start(self):
1093 self._abort = False
1094 self._stop = False
1095 QtCore.QThread.start(self)
1097 def pause(self):
1098 self._mutex.lock()
1099 self._stop = True
1100 self._mutex.unlock()
1102 def resume(self):
1103 self._mutex.lock()
1104 self._stop = False
1105 self._mutex.unlock()
1106 self._condition.wakeOne()
1108 def stop(self):
1109 self._abort = True
1110 self.wait()
1113 class Cache(object):
1115 _label_font = None
1117 @classmethod
1118 def label_font(cls):
1119 font = cls._label_font
1120 if font is None:
1121 font = cls._label_font = QtWidgets.QApplication.font()
1122 font.setPointSize(6)
1123 return font
1126 class Edge(QtWidgets.QGraphicsItem):
1127 item_type = qtutils.standard_item_type_value(1)
1129 def __init__(self, source, dest):
1131 QtWidgets.QGraphicsItem.__init__(self)
1133 self.setAcceptedMouseButtons(Qt.NoButton)
1134 self.source = source
1135 self.dest = dest
1136 self.commit = source.commit
1137 self.setZValue(-2)
1139 self.recompute_bound()
1140 self.path = None
1141 self.path_valid = False
1143 # Choose a new color for new branch edges
1144 if self.source.x() < self.dest.x():
1145 color = EdgeColor.cycle()
1146 line = Qt.SolidLine
1147 elif self.source.x() != self.dest.x():
1148 color = EdgeColor.current()
1149 line = Qt.SolidLine
1150 else:
1151 color = EdgeColor.current()
1152 line = Qt.SolidLine
1154 self.pen = QtGui.QPen(color, 4.0, line, Qt.SquareCap, Qt.RoundJoin)
1156 def recompute_bound(self):
1157 dest_pt = Commit.item_bbox.center()
1159 self.source_pt = self.mapFromItem(self.source, dest_pt)
1160 self.dest_pt = self.mapFromItem(self.dest, dest_pt)
1161 self.line = QtCore.QLineF(self.source_pt, self.dest_pt)
1163 width = self.dest_pt.x() - self.source_pt.x()
1164 height = self.dest_pt.y() - self.source_pt.y()
1165 rect = QtCore.QRectF(self.source_pt, QtCore.QSizeF(width, height))
1166 self.bound = rect.normalized()
1168 def commits_were_invalidated(self):
1169 self.recompute_bound()
1170 self.prepareGeometryChange()
1171 # The path should not be recomputed immediately because just small part
1172 # of DAG is actually shown at same time. It will be recomputed on
1173 # demand in course of 'paint' method.
1174 self.path_valid = False
1175 # Hence, just queue redrawing.
1176 self.update()
1178 # Qt overrides
1179 def type(self):
1180 return self.item_type
1182 def boundingRect(self):
1183 return self.bound
1185 def recompute_path(self):
1186 QRectF = QtCore.QRectF
1187 QPointF = QtCore.QPointF
1189 arc_rect = 10
1190 connector_length = 5
1192 path = QtGui.QPainterPath()
1194 if self.source.x() == self.dest.x():
1195 path.moveTo(self.source.x(), self.source.y())
1196 path.lineTo(self.dest.x(), self.dest.y())
1197 else:
1198 # Define points starting from source
1199 point1 = QPointF(self.source.x(), self.source.y())
1200 point2 = QPointF(point1.x(), point1.y() - connector_length)
1201 point3 = QPointF(point2.x() + arc_rect, point2.y() - arc_rect)
1203 # Define points starting from dest
1204 point4 = QPointF(self.dest.x(), self.dest.y())
1205 point5 = QPointF(point4.x(), point3.y() - arc_rect)
1206 point6 = QPointF(point5.x() - arc_rect, point5.y() + arc_rect)
1208 start_angle_arc1 = 180
1209 span_angle_arc1 = 90
1210 start_angle_arc2 = 90
1211 span_angle_arc2 = -90
1213 # If the dest is at the left of the source, then we
1214 # need to reverse some values
1215 if self.source.x() > self.dest.x():
1216 point3 = QPointF(point2.x() - arc_rect, point3.y())
1217 point6 = QPointF(point5.x() + arc_rect, point6.y())
1219 span_angle_arc1 = 90
1221 path.moveTo(point1)
1222 path.lineTo(point2)
1223 path.arcTo(QRectF(point2, point3), start_angle_arc1, span_angle_arc1)
1224 path.lineTo(point6)
1225 path.arcTo(QRectF(point6, point5), start_angle_arc2, span_angle_arc2)
1226 path.lineTo(point4)
1228 self.path = path
1229 self.path_valid = True
1231 def paint(self, painter, _option, _widget):
1232 if not self.path_valid:
1233 self.recompute_path()
1234 painter.setPen(self.pen)
1235 painter.drawPath(self.path)
1238 class EdgeColor(object):
1239 """An edge color factory"""
1241 current_color_index = 0
1242 colors = [
1243 QtGui.QColor(Qt.red),
1244 QtGui.QColor(Qt.cyan),
1245 QtGui.QColor(Qt.magenta),
1246 QtGui.QColor(Qt.green),
1247 # Orange; Qt.yellow is too low-contrast
1248 qtutils.rgba(0xFF, 0x66, 0x00),
1251 @classmethod
1252 def update_colors(cls, theme):
1253 """Update the colors based on the color theme"""
1254 if theme.is_dark or theme.is_palette_dark:
1255 cls.colors.extend([
1256 QtGui.QColor(Qt.red).lighter(),
1257 QtGui.QColor(Qt.cyan).lighter(),
1258 QtGui.QColor(Qt.magenta).lighter(),
1259 QtGui.QColor(Qt.green).lighter(),
1260 QtGui.QColor(Qt.yellow).lighter(),
1262 else:
1263 cls.colors.extend([
1264 QtGui.QColor(Qt.blue),
1265 QtGui.QColor(Qt.darkRed),
1266 QtGui.QColor(Qt.darkCyan),
1267 QtGui.QColor(Qt.darkMagenta),
1268 QtGui.QColor(Qt.darkGreen),
1269 QtGui.QColor(Qt.darkYellow),
1270 QtGui.QColor(Qt.darkBlue),
1273 @classmethod
1274 def cycle(cls):
1275 cls.current_color_index += 1
1276 cls.current_color_index %= len(cls.colors)
1277 color = cls.colors[cls.current_color_index]
1278 color.setAlpha(128)
1279 return color
1281 @classmethod
1282 def current(cls):
1283 return cls.colors[cls.current_color_index]
1285 @classmethod
1286 def reset(cls):
1287 cls.current_color_index = 0
1290 class Commit(QtWidgets.QGraphicsItem):
1291 item_type = qtutils.standard_item_type_value(2)
1292 commit_radius = 12.0
1293 merge_radius = 18.0
1295 item_shape = QtGui.QPainterPath()
1296 item_shape.addRect(
1297 commit_radius / -2.0, commit_radius / -2.0, commit_radius, commit_radius
1299 item_bbox = item_shape.boundingRect()
1301 inner_rect = QtGui.QPainterPath()
1302 inner_rect.addRect(
1303 commit_radius / -2.0 + 2.0,
1304 commit_radius / -2.0 + 2.0,
1305 commit_radius - 4.0,
1306 commit_radius - 4.0,
1308 inner_rect = inner_rect.boundingRect()
1310 commit_color = QtGui.QColor(Qt.white)
1311 outline_color = commit_color.darker()
1312 merge_color = QtGui.QColor(Qt.lightGray)
1314 commit_selected_color = QtGui.QColor(Qt.green)
1315 selected_outline_color = commit_selected_color.darker()
1317 commit_pen = QtGui.QPen()
1318 commit_pen.setWidth(1)
1319 commit_pen.setColor(outline_color)
1321 def __init__(
1322 self,
1323 commit,
1324 selectable=QtWidgets.QGraphicsItem.ItemIsSelectable,
1325 cursor=Qt.PointingHandCursor,
1326 xpos=commit_radius / 2.0 + 1.0,
1327 cached_commit_color=commit_color,
1328 cached_merge_color=merge_color,
1330 QtWidgets.QGraphicsItem.__init__(self)
1332 self.commit = commit
1333 self.selected = False
1335 self.setZValue(0)
1336 self.setFlag(selectable)
1337 self.setCursor(cursor)
1338 self.setToolTip(commit.oid[:12] + ': ' + commit.summary)
1340 if commit.tags:
1341 self.label = label = Label(commit)
1342 label.setParentItem(self)
1343 label.setPos(xpos + 1, -self.commit_radius / 2.0)
1344 else:
1345 self.label = None
1347 if len(commit.parents) > 1:
1348 self.brush = cached_merge_color
1349 else:
1350 self.brush = cached_commit_color
1352 self.pressed = False
1353 self.dragged = False
1354 self.edges = {}
1356 def itemChange(self, change, value):
1357 if change == QtWidgets.QGraphicsItem.ItemSelectedHasChanged:
1358 # Cache the pen for use in paint()
1359 if value:
1360 self.brush = self.commit_selected_color
1361 color = self.selected_outline_color
1362 else:
1363 if len(self.commit.parents) > 1:
1364 self.brush = self.merge_color
1365 else:
1366 self.brush = self.commit_color
1367 color = self.outline_color
1368 commit_pen = QtGui.QPen()
1369 commit_pen.setWidth(1)
1370 commit_pen.setColor(color)
1371 self.commit_pen = commit_pen
1373 return QtWidgets.QGraphicsItem.itemChange(self, change, value)
1375 def type(self):
1376 return self.item_type
1378 def boundingRect(self):
1379 return self.item_bbox
1381 def shape(self):
1382 return self.item_shape
1384 def paint(self, painter, option, _widget):
1386 # Do not draw outside the exposed rect
1387 painter.setClipRect(option.exposedRect)
1389 # Draw ellipse
1390 painter.setPen(self.commit_pen)
1391 painter.setBrush(self.brush)
1392 painter.drawEllipse(self.inner_rect)
1394 def mousePressEvent(self, event):
1395 QtWidgets.QGraphicsItem.mousePressEvent(self, event)
1396 self.pressed = True
1397 self.selected = self.isSelected()
1399 def mouseMoveEvent(self, event):
1400 if self.pressed:
1401 self.dragged = True
1402 QtWidgets.QGraphicsItem.mouseMoveEvent(self, event)
1404 def mouseReleaseEvent(self, event):
1405 QtWidgets.QGraphicsItem.mouseReleaseEvent(self, event)
1406 if not self.dragged and self.selected and event.button() == Qt.LeftButton:
1407 return
1408 self.pressed = False
1409 self.dragged = False
1412 class Label(QtWidgets.QGraphicsItem):
1414 item_type = qtutils.graphics_item_type_value(3)
1416 head_color = QtGui.QColor(Qt.green)
1417 other_color = QtGui.QColor(Qt.white)
1418 remote_color = QtGui.QColor(Qt.yellow)
1420 head_pen = QtGui.QPen()
1421 head_pen.setColor(head_color.darker().darker())
1422 head_pen.setWidth(1)
1424 text_pen = QtGui.QPen()
1425 text_pen.setColor(QtGui.QColor(Qt.black))
1426 text_pen.setWidth(1)
1428 alpha = 200
1429 head_color.setAlpha(alpha)
1430 other_color.setAlpha(alpha)
1431 remote_color.setAlpha(alpha)
1433 border = 2
1434 item_spacing = 5
1435 text_offset = 1
1437 def __init__(self, commit):
1438 QtWidgets.QGraphicsItem.__init__(self)
1439 self.setZValue(-1)
1440 self.commit = commit
1442 def type(self):
1443 return self.item_type
1445 def boundingRect(self, cache=Cache):
1446 QPainterPath = QtGui.QPainterPath
1447 QRectF = QtCore.QRectF
1449 width = 72
1450 height = 18
1451 current_width = 0
1452 spacing = self.item_spacing
1453 border = self.border + self.text_offset # text offset=1 in paint()
1455 font = cache.label_font()
1456 item_shape = QPainterPath()
1458 base_rect = QRectF(0, 0, width, height)
1459 base_rect = base_rect.adjusted(-border, -border, border, border)
1460 item_shape.addRect(base_rect)
1462 for tag in self.commit.tags:
1463 text_shape = QPainterPath()
1464 text_shape.addText(current_width, 0, font, tag)
1465 text_rect = text_shape.boundingRect()
1466 box_rect = text_rect.adjusted(-border, -border, border, border)
1467 item_shape.addRect(box_rect)
1468 current_width = item_shape.boundingRect().width() + spacing
1470 return item_shape.boundingRect()
1472 def paint(self, painter, _option, _widget, cache=Cache):
1473 # Draw tags and branches
1474 font = cache.label_font()
1475 painter.setFont(font)
1477 current_width = 0
1478 border = self.border
1479 offset = self.text_offset
1480 spacing = self.item_spacing
1481 QRectF = QtCore.QRectF
1483 HEAD = 'HEAD'
1484 remotes_prefix = 'remotes/'
1485 tags_prefix = 'tags/'
1486 heads_prefix = 'heads/'
1487 remotes_len = len(remotes_prefix)
1488 tags_len = len(tags_prefix)
1489 heads_len = len(heads_prefix)
1491 for tag in self.commit.tags:
1492 if tag == HEAD:
1493 painter.setPen(self.text_pen)
1494 painter.setBrush(self.remote_color)
1495 elif tag.startswith(remotes_prefix):
1496 tag = tag[remotes_len:]
1497 painter.setPen(self.text_pen)
1498 painter.setBrush(self.other_color)
1499 elif tag.startswith(tags_prefix):
1500 tag = tag[tags_len:]
1501 painter.setPen(self.text_pen)
1502 painter.setBrush(self.remote_color)
1503 elif tag.startswith(heads_prefix):
1504 tag = tag[heads_len:]
1505 painter.setPen(self.head_pen)
1506 painter.setBrush(self.head_color)
1507 else:
1508 painter.setPen(self.text_pen)
1509 painter.setBrush(self.other_color)
1511 text_rect = painter.boundingRect(
1512 QRectF(current_width, 0, 0, 0), Qt.TextSingleLine, tag
1514 box_rect = text_rect.adjusted(-offset, -offset, offset, offset)
1516 painter.drawRoundedRect(box_rect, border, border)
1517 painter.drawText(text_rect, Qt.TextSingleLine, tag)
1518 current_width += text_rect.width() + spacing
1521 # pylint: disable=too-many-ancestors
1522 class GraphView(QtWidgets.QGraphicsView, ViewerMixin):
1524 commits_selected = Signal(object)
1525 diff_commits = Signal(object, object)
1527 x_adjust = int(Commit.commit_radius * 4 / 3)
1528 y_adjust = int(Commit.commit_radius * 4 / 3)
1530 x_off = -18
1531 y_off = -24
1533 def __init__(self, context, parent):
1534 QtWidgets.QGraphicsView.__init__(self, parent)
1535 ViewerMixin.__init__(self)
1536 EdgeColor.update_colors(context.app.theme)
1538 theme = context.app.theme
1539 highlight = theme.selection_color()
1540 Commit.commit_selected_color = highlight
1541 Commit.selected_outline_color = highlight.darker()
1543 self.context = context
1544 self.columns = {}
1545 self.menu_actions = None
1546 self.commits = []
1547 self.items = {}
1548 self.mouse_start = [0, 0]
1549 self.saved_matrix = self.transform()
1550 self.max_column = 0
1551 self.min_column = 0
1552 self.frontier = {}
1553 self.tagged_cells = set()
1555 self.x_start = 24
1556 self.x_min = 24
1557 self.x_offsets = collections.defaultdict(lambda: self.x_min)
1559 self.is_panning = False
1560 self.pressed = False
1561 self.selecting = False
1562 self.last_mouse = [0, 0]
1563 self.zoom = 2
1564 self.setDragMode(self.RubberBandDrag)
1566 scene = QtWidgets.QGraphicsScene(self)
1567 scene.setItemIndexMethod(QtWidgets.QGraphicsScene.BspTreeIndex)
1568 self.setScene(scene)
1570 # pylint: disable=no-member
1571 scene.selectionChanged.connect(self.selection_changed)
1573 self.setRenderHint(QtGui.QPainter.Antialiasing)
1574 self.setViewportUpdateMode(self.SmartViewportUpdate)
1575 self.setCacheMode(QtWidgets.QGraphicsView.CacheBackground)
1576 self.setTransformationAnchor(QtWidgets.QGraphicsView.AnchorUnderMouse)
1577 self.setResizeAnchor(QtWidgets.QGraphicsView.NoAnchor)
1579 background_color = qtutils.css_color(context.app.theme.background_color_rgb())
1580 self.setBackgroundBrush(background_color)
1582 qtutils.add_action(
1583 self,
1584 N_('Zoom In'),
1585 self.zoom_in,
1586 hotkeys.ZOOM_IN,
1587 hotkeys.ZOOM_IN_SECONDARY,
1590 qtutils.add_action(self, N_('Zoom Out'), self.zoom_out, hotkeys.ZOOM_OUT)
1592 qtutils.add_action(self, N_('Zoom to Fit'), self.zoom_to_fit, hotkeys.FIT)
1594 qtutils.add_action(
1595 self, N_('Select Parent'), self._select_parent, hotkeys.MOVE_DOWN_TERTIARY
1598 qtutils.add_action(
1599 self,
1600 N_('Select Oldest Parent'),
1601 self._select_oldest_parent,
1602 hotkeys.MOVE_DOWN,
1605 qtutils.add_action(
1606 self, N_('Select Child'), self._select_child, hotkeys.MOVE_UP_TERTIARY
1609 qtutils.add_action(
1610 self, N_('Select Newest Child'), self._select_newest_child, hotkeys.MOVE_UP
1613 def clear(self):
1614 EdgeColor.reset()
1615 self.scene().clear()
1616 self.items.clear()
1617 self.x_offsets.clear()
1618 self.x_min = 24
1619 self.commits = []
1621 # ViewerMixin interface
1622 def selected_items(self):
1623 """Return the currently selected items"""
1624 return self.scene().selectedItems()
1626 def zoom_in(self):
1627 self.scale_view(1.5)
1629 def zoom_out(self):
1630 self.scale_view(1.0 / 1.5)
1632 def selection_changed(self):
1633 # Broadcast selection to other widgets
1634 selected_items = self.scene().selectedItems()
1635 commits = sort_by_generation([item.commit for item in selected_items])
1636 self.set_selecting(True)
1637 self.commits_selected.emit(commits)
1638 self.set_selecting(False)
1640 def select_commits(self, commits):
1641 if self.selecting:
1642 return
1643 with qtutils.BlockSignals(self.scene()):
1644 self.select([commit.oid for commit in commits])
1646 def select(self, oids):
1647 """Select the item for the oids"""
1648 self.scene().clearSelection()
1649 for oid in oids:
1650 try:
1651 item = self.items[oid]
1652 except KeyError:
1653 continue
1654 item.setSelected(True)
1655 item_rect = item.sceneTransform().mapRect(item.boundingRect())
1656 self.ensureVisible(item_rect)
1658 def _get_item_by_generation(self, commits, criteria_func):
1659 """Return the item for the commit matching criteria"""
1660 if not commits:
1661 return None
1662 generation = None
1663 for commit in commits:
1664 if generation is None or criteria_func(generation, commit.generation):
1665 oid = commit.oid
1666 generation = commit.generation
1667 try:
1668 return self.items[oid]
1669 except KeyError:
1670 return None
1672 def _oldest_item(self, commits):
1673 """Return the item for the commit with the oldest generation number"""
1674 return self._get_item_by_generation(commits, lambda a, b: a > b)
1676 def _newest_item(self, commits):
1677 """Return the item for the commit with the newest generation number"""
1678 return self._get_item_by_generation(commits, lambda a, b: a < b)
1680 def create_patch(self):
1681 items = self.selected_items()
1682 if not items:
1683 return
1684 context = self.context
1685 selected_commits = sort_by_generation([n.commit for n in items])
1686 oids = [c.oid for c in selected_commits]
1687 all_oids = [c.oid for c in sort_by_generation(self.commits)]
1688 cmds.do(cmds.FormatPatch, context, oids, all_oids)
1690 def _select_parent(self):
1691 """Select the parent with the newest generation number"""
1692 selected_item = self.selected_item()
1693 if selected_item is None:
1694 return
1695 parent_item = self._newest_item(selected_item.commit.parents)
1696 if parent_item is None:
1697 return
1698 selected_item.setSelected(False)
1699 parent_item.setSelected(True)
1700 self.ensureVisible(parent_item.mapRectToScene(parent_item.boundingRect()))
1702 def _select_oldest_parent(self):
1703 """Select the parent with the oldest generation number"""
1704 selected_item = self.selected_item()
1705 if selected_item is None:
1706 return
1707 parent_item = self._oldest_item(selected_item.commit.parents)
1708 if parent_item is None:
1709 return
1710 selected_item.setSelected(False)
1711 parent_item.setSelected(True)
1712 scene_rect = parent_item.mapRectToScene(parent_item.boundingRect())
1713 self.ensureVisible(scene_rect)
1715 def _select_child(self):
1716 """Select the child with the oldest generation number"""
1717 selected_item = self.selected_item()
1718 if selected_item is None:
1719 return
1720 child_item = self._oldest_item(selected_item.commit.children)
1721 if child_item is None:
1722 return
1723 selected_item.setSelected(False)
1724 child_item.setSelected(True)
1725 scene_rect = child_item.mapRectToScene(child_item.boundingRect())
1726 self.ensureVisible(scene_rect)
1728 def _select_newest_child(self):
1729 """Select the Nth child with the newest generation number (N > 1)"""
1730 selected_item = self.selected_item()
1731 if selected_item is None:
1732 return
1733 if len(selected_item.commit.children) > 1:
1734 children = selected_item.commit.children[1:]
1735 else:
1736 children = selected_item.commit.children
1737 child_item = self._newest_item(children)
1738 if child_item is None:
1739 return
1740 selected_item.setSelected(False)
1741 child_item.setSelected(True)
1742 scene_rect = child_item.mapRectToScene(child_item.boundingRect())
1743 self.ensureVisible(scene_rect)
1745 def set_initial_view(self):
1746 items = []
1747 selected = self.selected_items()
1748 if selected:
1749 items.extend(selected)
1751 if not selected and self.commits:
1752 commit = self.commits[-1]
1753 items.append(self.items[commit.oid])
1755 self.setSceneRect(self.scene().itemsBoundingRect())
1756 self.fit_view_to_items(items)
1758 def zoom_to_fit(self):
1759 """Fit selected items into the viewport"""
1760 items = self.selected_items()
1761 self.fit_view_to_items(items)
1763 def fit_view_to_items(self, items):
1764 if not items:
1765 rect = self.scene().itemsBoundingRect()
1766 else:
1767 x_min = y_min = maxsize
1768 x_max = y_max = -maxsize
1770 for item in items:
1771 pos = item.pos()
1772 x_val = pos.x()
1773 y_val = pos.y()
1774 x_min = min(x_min, x_val)
1775 x_max = max(x_max, x_val)
1776 y_min = min(y_min, y_val)
1777 y_max = max(y_max, y_val)
1779 rect = QtCore.QRectF(x_min, y_min, abs(x_max - x_min), abs(y_max - y_min))
1781 x_adjust = abs(GraphView.x_adjust)
1782 y_adjust = abs(GraphView.y_adjust)
1784 count = max(2.0, 10.0 - len(items) / 2.0)
1785 y_offset = int(y_adjust * count)
1786 x_offset = int(x_adjust * count)
1787 rect.setX(rect.x() - x_offset // 2)
1788 rect.setY(rect.y() - y_adjust // 2)
1789 rect.setHeight(rect.height() + y_offset)
1790 rect.setWidth(rect.width() + x_offset)
1792 self.fitInView(rect, Qt.KeepAspectRatio)
1793 self.scene().invalidate()
1795 def handle_event(self, event_handler, event, update=True):
1796 event_handler(self, event)
1797 if update:
1798 self.update()
1800 def set_selecting(self, selecting):
1801 self.selecting = selecting
1803 def pan(self, event):
1804 pos = event.pos()
1805 x_offset = pos.x() - self.mouse_start[0]
1806 y_offset = pos.y() - self.mouse_start[1]
1808 if x_offset == 0 and y_offset == 0:
1809 return
1811 rect = QtCore.QRect(0, 0, abs(x_offset), abs(y_offset))
1812 delta = self.mapToScene(rect).boundingRect()
1814 x_translate = delta.width()
1815 if x_offset < 0.0:
1816 x_translate = -x_translate
1818 y_translate = delta.height()
1819 if y_offset < 0.0:
1820 y_translate = -y_translate
1822 matrix = self.transform()
1823 matrix.reset()
1824 matrix *= self.saved_matrix
1825 matrix.translate(x_translate, y_translate)
1827 self.setTransformationAnchor(QtWidgets.QGraphicsView.NoAnchor)
1828 self.setTransform(matrix)
1830 def wheel_zoom(self, event):
1831 """Handle mouse wheel zooming."""
1832 delta = qtcompat.wheel_delta(event)
1833 zoom = math.pow(2.0, delta / 512.0)
1834 factor = (
1835 self.transform()
1836 .scale(zoom, zoom)
1837 .mapRect(QtCore.QRectF(0.0, 0.0, 1.0, 1.0))
1838 .width()
1840 if factor < 0.014 or factor > 42.0:
1841 return
1842 self.setTransformationAnchor(QtWidgets.QGraphicsView.AnchorUnderMouse)
1843 self.zoom = zoom
1844 self.scale(zoom, zoom)
1846 def wheel_pan(self, event):
1847 """Handle mouse wheel panning."""
1848 unit = QtCore.QRectF(0.0, 0.0, 1.0, 1.0)
1849 factor = 1.0 / self.transform().mapRect(unit).width()
1850 tx, ty = qtcompat.wheel_translation(event)
1852 matrix = self.transform().translate(tx * factor, ty * factor)
1853 self.setTransformationAnchor(QtWidgets.QGraphicsView.NoAnchor)
1854 self.setTransform(matrix)
1856 def scale_view(self, scale):
1857 factor = (
1858 self.transform()
1859 .scale(scale, scale)
1860 .mapRect(QtCore.QRectF(0, 0, 1, 1))
1861 .width()
1863 if factor < 0.07 or factor > 100.0:
1864 return
1865 self.zoom = scale
1867 adjust_scrollbars = True
1868 scrollbar = self.verticalScrollBar()
1869 if scrollbar:
1870 value = get(scrollbar)
1871 min_ = scrollbar.minimum()
1872 max_ = scrollbar.maximum()
1873 range_ = max_ - min_
1874 distance = value - min_
1875 nonzero_range = range_ > 0.1
1876 if nonzero_range:
1877 scrolloffset = distance / range_
1878 else:
1879 adjust_scrollbars = False
1881 self.setTransformationAnchor(QtWidgets.QGraphicsView.NoAnchor)
1882 self.scale(scale, scale)
1884 scrollbar = self.verticalScrollBar()
1885 if scrollbar and adjust_scrollbars:
1886 min_ = scrollbar.minimum()
1887 max_ = scrollbar.maximum()
1888 range_ = max_ - min_
1889 value = min_ + int(float(range_) * scrolloffset)
1890 scrollbar.setValue(value)
1892 def add_commits(self, commits):
1893 """Traverse commits and add them to the view."""
1894 self.commits.extend(commits)
1895 scene = self.scene()
1896 for commit in commits:
1897 item = Commit(commit)
1898 self.items[commit.oid] = item
1899 for ref in commit.tags:
1900 self.items[ref] = item
1901 scene.addItem(item)
1903 self.layout_commits()
1904 self.link(commits)
1906 def link(self, commits):
1907 """Create edges linking commits with their parents"""
1908 scene = self.scene()
1909 for commit in commits:
1910 try:
1911 commit_item = self.items[commit.oid]
1912 except KeyError:
1913 continue # The history is truncated.
1914 for parent in reversed(commit.parents):
1915 try:
1916 parent_item = self.items[parent.oid]
1917 except KeyError:
1918 continue # The history is truncated.
1919 try:
1920 edge = parent_item.edges[commit.oid]
1921 except KeyError:
1922 edge = Edge(parent_item, commit_item)
1923 else:
1924 continue
1925 parent_item.edges[commit.oid] = edge
1926 commit_item.edges[parent.oid] = edge
1927 scene.addItem(edge)
1929 def layout_commits(self):
1930 positions = self.position_nodes()
1932 # Each edge is accounted in two commits. Hence, accumulate invalid
1933 # edges to prevent double edge invalidation.
1934 invalid_edges = set()
1936 for oid, (x_val, y_val) in positions.items():
1937 item = self.items[oid]
1939 pos = item.pos()
1940 if pos != (x_val, y_val):
1941 item.setPos(x_val, y_val)
1943 for edge in item.edges.values():
1944 invalid_edges.add(edge)
1946 for edge in invalid_edges:
1947 edge.commits_were_invalidated()
1949 # Commit node layout technique
1951 # Nodes are aligned by a mesh. Columns and rows are distributed using
1952 # algorithms described below.
1954 # Row assignment algorithm
1956 # The algorithm aims consequent.
1957 # 1. A commit should be above all its parents.
1958 # 2. No commit should be at right side of a commit with a tag in same row.
1959 # This prevents overlapping of tag labels with commits and other labels.
1960 # 3. Commit density should be maximized.
1962 # The algorithm requires that all parents of a commit were assigned column.
1963 # Nodes must be traversed in generation ascend order. This guarantees that all
1964 # parents of a commit were assigned row. So, the algorithm may operate in
1965 # course of column assignment algorithm.
1967 # Row assignment uses frontier. A frontier is a dictionary that contains
1968 # minimum available row index for each column. It propagates during the
1969 # algorithm. Set of cells with tags is also maintained to meet second aim.
1971 # Initialization is performed by reset_rows method. Each new column should
1972 # be declared using declare_column method. Getting row for a cell is
1973 # implemented in alloc_cell method. Frontier must be propagated for any child
1974 # of fork commit which occupies different column. This meets first aim.
1976 # Column assignment algorithm
1978 # The algorithm traverses nodes in generation ascend order. This guarantees
1979 # that a node will be visited after all its parents.
1981 # The set of occupied columns are maintained during work. Initially it is
1982 # empty and no node occupied a column. Empty columns are allocated on demand.
1983 # Free index for column being allocated is searched in following way.
1984 # 1. Start from desired column and look towards graph center (0 column).
1985 # 2. Start from center and look in both directions simultaneously.
1986 # Desired column is defaulted to 0. Fork node should set desired column for
1987 # children equal to its one. This prevents branch from jumping too far from
1988 # its fork.
1990 # Initialization is performed by reset_columns method. Column allocation is
1991 # implemented in alloc_column method. Initialization and main loop are in
1992 # recompute_grid method. The method also embeds row assignment algorithm by
1993 # implementation.
1995 # Actions for each node are follow.
1996 # 1. If the node was not assigned a column then it is assigned empty one.
1997 # 2. Allocate row.
1998 # 3. Allocate columns for children.
1999 # If a child have a column assigned then it should no be overridden. One of
2000 # children is assigned same column as the node. If the node is a fork then the
2001 # child is chosen in generation descent order. This is a heuristic and it only
2002 # affects resulting appearance of the graph. Other children are assigned empty
2003 # columns in same order. It is the heuristic too.
2004 # 4. If no child occupies column of the node then leave it.
2005 # It is possible in consequent situations.
2006 # 4.1 The node is a leaf.
2007 # 4.2 The node is a fork and all its children are already assigned side
2008 # column. It is possible if all the children are merges.
2009 # 4.3 Single node child is a merge that is already assigned a column.
2010 # 5. Propagate frontier with respect to this node.
2011 # Each frontier entry corresponding to column occupied by any node's child
2012 # must be gather than node row index. This meets first aim of the row
2013 # assignment algorithm.
2014 # Note that frontier of child that occupies same row was propagated during
2015 # step 2. Hence, it must be propagated for children on side columns.
2017 def reset_columns(self):
2018 # Some children of displayed commits might not be accounted in
2019 # 'commits' list. It is common case during loading of big graph.
2020 # But, they are assigned a column that must be reseted. Hence, use
2021 # depth-first traversal to reset all columns assigned.
2022 for node in self.commits:
2023 if node.column is None:
2024 continue
2025 stack = [node]
2026 while stack:
2027 node = stack.pop()
2028 node.column = None
2029 for child in node.children:
2030 if child.column is not None:
2031 stack.append(child)
2033 self.columns = {}
2034 self.max_column = 0
2035 self.min_column = 0
2037 def reset_rows(self):
2038 self.frontier = {}
2039 self.tagged_cells = set()
2041 def declare_column(self, column):
2042 if self.frontier:
2043 # Align new column frontier by frontier of nearest column. If all
2044 # columns were left then select maximum frontier value.
2045 if not self.columns:
2046 self.frontier[column] = max(list(self.frontier.values()))
2047 return
2048 # This is heuristic that mostly affects roots. Note that the
2049 # frontier values for fork children will be overridden in course of
2050 # propagate_frontier.
2051 for offset in itertools.count(1):
2052 for value in (column + offset, column - offset):
2053 if value not in self.columns:
2054 # Column is not occupied.
2055 continue
2056 try:
2057 frontier = self.frontier[value]
2058 except KeyError:
2059 # Column 'c' was never allocated.
2060 continue
2062 frontier -= 1
2063 # The frontier of the column may be higher because of
2064 # tag overlapping prevention performed for previous head.
2065 try:
2066 if self.frontier[column] >= frontier:
2067 break
2068 except KeyError:
2069 pass
2071 self.frontier[column] = frontier
2072 break
2073 else:
2074 continue
2075 break
2076 else:
2077 # First commit must be assigned 0 row.
2078 self.frontier[column] = 0
2080 def alloc_column(self, column=0):
2081 columns = self.columns
2082 # First, look for free column by moving from desired column to graph
2083 # center (column 0).
2084 for c in range(column, 0, -1 if column > 0 else 1):
2085 if c not in columns:
2086 if c > self.max_column:
2087 self.max_column = c
2088 elif c < self.min_column:
2089 self.min_column = c
2090 break
2091 else:
2092 # If no free column was found between graph center and desired
2093 # column then look for free one by moving from center along both
2094 # directions simultaneously.
2095 for c in itertools.count(0):
2096 if c not in columns:
2097 if c > self.max_column:
2098 self.max_column = c
2099 break
2100 c = -c
2101 if c not in columns:
2102 if c < self.min_column:
2103 self.min_column = c
2104 break
2105 self.declare_column(c)
2106 columns[c] = 1
2107 return c
2109 def alloc_cell(self, column, tags):
2110 # Get empty cell from frontier.
2111 cell_row = self.frontier[column]
2113 if tags:
2114 # Prevent overlapping of tag with cells already allocated a row.
2115 if self.x_off > 0:
2116 can_overlap = list(range(column + 1, self.max_column + 1))
2117 else:
2118 can_overlap = list(range(column - 1, self.min_column - 1, -1))
2119 for value in can_overlap:
2120 frontier = self.frontier[value]
2121 if frontier > cell_row:
2122 cell_row = frontier
2124 # Avoid overlapping with tags of commits at cell_row.
2125 if self.x_off > 0:
2126 can_overlap = list(range(self.min_column, column))
2127 else:
2128 can_overlap = list(range(self.max_column, column, -1))
2129 for cell_row in itertools.count(cell_row):
2130 for value in can_overlap:
2131 if (value, cell_row) in self.tagged_cells:
2132 # Overlapping. Try next row.
2133 break
2134 else:
2135 # No overlapping was found.
2136 break
2137 # Note that all checks should be made for new cell_row value.
2139 if tags:
2140 self.tagged_cells.add((column, cell_row))
2142 # Propagate frontier.
2143 self.frontier[column] = cell_row + 1
2144 return cell_row
2146 def propagate_frontier(self, column, value):
2147 current = self.frontier[column]
2148 if current < value:
2149 self.frontier[column] = value
2151 def leave_column(self, column):
2152 count = self.columns[column]
2153 if count == 1:
2154 del self.columns[column]
2155 else:
2156 self.columns[column] = count - 1
2158 def recompute_grid(self):
2159 self.reset_columns()
2160 self.reset_rows()
2162 for node in sort_by_generation(list(self.commits)):
2163 if node.column is None:
2164 # Node is either root or its parent is not in items. The last
2165 # happens when tree loading is in progress. Allocate new
2166 # columns for such nodes.
2167 node.column = self.alloc_column()
2169 node.row = self.alloc_cell(node.column, node.tags)
2171 # Allocate columns for children which are still without one. Also
2172 # propagate frontier for children.
2173 if node.is_fork():
2174 sorted_children = sorted(
2175 node.children, key=lambda c: c.generation, reverse=True
2177 citer = iter(sorted_children)
2178 for child in citer:
2179 if child.column is None:
2180 # Top most child occupies column of parent.
2181 child.column = node.column
2182 # Note that frontier is propagated in course of
2183 # alloc_cell.
2184 break
2185 self.propagate_frontier(child.column, node.row + 1)
2186 else:
2187 # No child occupies same column.
2188 self.leave_column(node.column)
2189 # Note that the loop below will pass no iteration.
2191 # Rest children are allocated new column.
2192 for child in citer:
2193 if child.column is None:
2194 child.column = self.alloc_column(node.column)
2195 self.propagate_frontier(child.column, node.row + 1)
2196 elif node.children:
2197 child = node.children[0]
2198 if child.column is None:
2199 child.column = node.column
2200 # Note that frontier is propagated in course of alloc_cell.
2201 elif child.column != node.column:
2202 # Child node have other parents and occupies column of one
2203 # of them.
2204 self.leave_column(node.column)
2205 # But frontier must be propagated with respect to this
2206 # parent.
2207 self.propagate_frontier(child.column, node.row + 1)
2208 else:
2209 # This is a leaf node.
2210 self.leave_column(node.column)
2212 def position_nodes(self):
2213 self.recompute_grid()
2215 x_start = self.x_start
2216 x_min = self.x_min
2217 x_off = self.x_off
2218 y_off = self.y_off
2220 positions = {}
2222 for node in self.commits:
2223 x_val = x_start + node.column * x_off
2224 y_val = y_off + node.row * y_off
2226 positions[node.oid] = (x_val, y_val)
2227 x_min = min(x_min, x_val)
2229 self.x_min = x_min
2231 return positions
2233 # Qt overrides
2234 def contextMenuEvent(self, event):
2235 self.context_menu_event(event)
2237 def mousePressEvent(self, event):
2238 if event.button() == Qt.MidButton:
2239 pos = event.pos()
2240 self.mouse_start = [pos.x(), pos.y()]
2241 self.saved_matrix = self.transform()
2242 self.is_panning = True
2243 return
2244 if event.button() == Qt.RightButton:
2245 event.ignore()
2246 return
2247 if event.button() == Qt.LeftButton:
2248 self.pressed = True
2249 self.handle_event(QtWidgets.QGraphicsView.mousePressEvent, event)
2251 def mouseMoveEvent(self, event):
2252 if self.is_panning:
2253 self.pan(event)
2254 return
2255 pos = self.mapToScene(event.pos())
2256 self.last_mouse[0] = pos.x()
2257 self.last_mouse[1] = pos.y()
2258 self.handle_event(QtWidgets.QGraphicsView.mouseMoveEvent, event, update=False)
2260 def mouseReleaseEvent(self, event):
2261 self.pressed = False
2262 if event.button() == Qt.MidButton:
2263 self.is_panning = False
2264 return
2265 self.handle_event(QtWidgets.QGraphicsView.mouseReleaseEvent, event)
2266 self.viewport().repaint()
2268 def wheelEvent(self, event):
2269 """Handle Qt mouse wheel events."""
2270 if event.modifiers() & Qt.ControlModifier:
2271 self.wheel_zoom(event)
2272 else:
2273 self.wheel_pan(event)
2275 def fitInView(self, rect, flags=Qt.IgnoreAspectRatio):
2276 """Override fitInView to remove unwanted margins
2278 https://bugreports.qt.io/browse/QTBUG-42331 - based on QT sources
2281 if self.scene() is None or rect.isNull():
2282 return
2283 unity = self.transform().mapRect(QtCore.QRectF(0, 0, 1, 1))
2284 self.scale(1.0 / unity.width(), 1.0 / unity.height())
2285 view_rect = self.viewport().rect()
2286 scene_rect = self.transform().mapRect(rect)
2287 xratio = view_rect.width() / scene_rect.width()
2288 yratio = view_rect.height() / scene_rect.height()
2289 if flags == Qt.KeepAspectRatio:
2290 xratio = yratio = min(xratio, yratio)
2291 elif flags == Qt.KeepAspectRatioByExpanding:
2292 xratio = yratio = max(xratio, yratio)
2293 self.scale(xratio, yratio)
2294 self.centerOn(rect.center())
2297 def sort_by_generation(commits):
2298 """Sort commits by their generation. Ensures consistent diffs and patch exports"""
2299 if len(commits) <= 1:
2300 return commits
2301 commits.sort(key=lambda x: x.generation)
2302 return commits
2305 # Glossary
2306 # ========
2307 # oid -- Git objects IDs (i.e. SHA-1 IDs)
2308 # ref -- Git references that resolve to a commit-ish (HEAD, branches, tags)