maint: format code using black
[git-cola.git] / cola / widgets / dag.py
blob264568249eb4e5592ef5f5e0f6a29e0ceae2317d
1 from __future__ import division, absolute_import, 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 ..qtutils import get
17 from .. import core
18 from .. import cmds
19 from .. import difftool
20 from .. import gitcmds
21 from .. import hotkeys
22 from .. import icons
23 from .. import observable
24 from .. import qtcompat
25 from .. import qtutils
26 from .. import utils
27 from . import archive
28 from . import browse
29 from . import completion
30 from . import createbranch
31 from . import createtag
32 from . import defs
33 from . import diff
34 from . import filelist
35 from . import standard
38 def git_dag(context, args=None, existing_view=None, show=True):
39 """Return a pre-populated git DAG widget."""
40 model = context.model
41 branch = model.currentbranch
42 # disambiguate between branch names and filenames by using '--'
43 branch_doubledash = (branch + ' --') if branch else ''
44 params = dag.DAG(branch_doubledash, 1000)
45 params.set_arguments(args)
47 if existing_view is None:
48 view = GitDAG(context, params)
49 else:
50 view = existing_view
51 view.set_params(params)
52 if params.ref:
53 view.display()
54 if show:
55 view.show()
56 return view
59 class FocusRedirectProxy(object):
60 """Redirect actions from the main widget to child widgets"""
62 def __init__(self, *widgets):
63 """Provide proxied widgets; the default widget must be first"""
64 self.widgets = widgets
65 self.default = widgets[0]
67 def __getattr__(self, name):
68 return lambda *args, **kwargs: self._forward_action(name, *args, **kwargs)
70 def _forward_action(self, name, *args, **kwargs):
71 """Forward the captured action to the focused or default widget"""
72 widget = QtWidgets.QApplication.focusWidget()
73 if widget in self.widgets and hasattr(widget, name):
74 fn = getattr(widget, name)
75 else:
76 fn = getattr(self.default, name)
78 return fn(*args, **kwargs)
81 class ViewerMixin(object):
82 """Implementations must provide selected_items()"""
84 def __init__(self):
85 self.context = None # provided by implementation
86 self.selected = None
87 self.clicked = None
88 self.menu_actions = None # provided by implementation
90 def selected_item(self):
91 """Return the currently selected item"""
92 selected_items = self.selected_items()
93 if not selected_items:
94 return None
95 return selected_items[0]
97 def selected_oid(self):
98 item = self.selected_item()
99 if item is None:
100 result = None
101 else:
102 result = item.commit.oid
103 return result
105 def selected_oids(self):
106 return [i.commit for i in self.selected_items()]
108 def with_oid(self, fn):
109 oid = self.selected_oid()
110 if oid:
111 result = fn(oid)
112 else:
113 result = None
114 return result
116 def diff_selected_this(self):
117 clicked_oid = self.clicked.oid
118 selected_oid = self.selected.oid
119 self.diff_commits.emit(selected_oid, clicked_oid)
121 def diff_this_selected(self):
122 clicked_oid = self.clicked.oid
123 selected_oid = self.selected.oid
124 self.diff_commits.emit(clicked_oid, selected_oid)
126 def cherry_pick(self):
127 context = self.context
128 self.with_oid(lambda oid: cmds.do(cmds.CherryPick, context, [oid]))
130 def revert(self):
131 context = self.context
132 self.with_oid(lambda oid: cmds.do(cmds.Revert, context, oid))
134 def copy_to_clipboard(self):
135 self.with_oid(qtutils.set_clipboard)
137 def create_branch(self):
138 context = self.context
139 create_new_branch = partial(createbranch.create_new_branch, context)
140 self.with_oid(lambda oid: create_new_branch(revision=oid))
142 def create_tag(self):
143 context = self.context
144 self.with_oid(lambda oid: createtag.create_tag(context, ref=oid))
146 def create_tarball(self):
147 context = self.context
148 self.with_oid(lambda oid: archive.show_save_dialog(context, oid, parent=self))
150 def show_diff(self):
151 context = self.context
152 self.with_oid(
153 lambda oid: difftool.diff_expression(
154 context, self, oid + '^!', hide_expr=False, focus_tree=True
158 def show_dir_diff(self):
159 context = self.context
160 self.with_oid(
161 lambda oid: cmds.difftool_launch(
162 context, left=oid, left_take_magic=True, dir_diff=True
166 def reset_mixed(self):
167 context = self.context
168 self.with_oid(lambda oid: cmds.do(cmds.ResetMixed, context, ref=oid))
170 def reset_keep(self):
171 context = self.context
172 self.with_oid(lambda oid: cmds.do(cmds.ResetKeep, context, ref=oid))
174 def reset_merge(self):
175 context = self.context
176 self.with_oid(lambda oid: cmds.do(cmds.ResetMerge, context, ref=oid))
178 def reset_soft(self):
179 context = self.context
180 self.with_oid(lambda oid: cmds.do(cmds.ResetSoft, context, ref=oid))
182 def reset_hard(self):
183 context = self.context
184 self.with_oid(lambda oid: cmds.do(cmds.ResetHard, context, ref=oid))
186 def restore_worktree(self):
187 context = self.context
188 self.with_oid(lambda oid: cmds.do(cmds.RestoreWorktree, context, ref=oid))
190 def checkout_detached(self):
191 context = self.context
192 self.with_oid(lambda oid: cmds.do(cmds.Checkout, context, [oid]))
194 def save_blob_dialog(self):
195 context = self.context
196 self.with_oid(lambda oid: browse.BrowseBranch.browse(context, oid))
198 def update_menu_actions(self, event):
199 selected_items = self.selected_items()
200 item = self.itemAt(event.pos())
201 if item is None:
202 self.clicked = commit = None
203 else:
204 self.clicked = commit = item.commit
206 has_single_selection = len(selected_items) == 1
207 has_selection = bool(selected_items)
208 can_diff = bool(
209 commit and has_single_selection and commit is not selected_items[0].commit
212 if can_diff:
213 self.selected = selected_items[0].commit
214 else:
215 self.selected = None
217 self.menu_actions['diff_this_selected'].setEnabled(can_diff)
218 self.menu_actions['diff_selected_this'].setEnabled(can_diff)
219 self.menu_actions['diff_commit'].setEnabled(has_single_selection)
220 self.menu_actions['diff_commit_all'].setEnabled(has_single_selection)
222 self.menu_actions['checkout_detached'].setEnabled(has_single_selection)
223 self.menu_actions['cherry_pick'].setEnabled(has_single_selection)
224 self.menu_actions['copy'].setEnabled(has_single_selection)
225 self.menu_actions['create_branch'].setEnabled(has_single_selection)
226 self.menu_actions['create_patch'].setEnabled(has_selection)
227 self.menu_actions['create_tag'].setEnabled(has_single_selection)
228 self.menu_actions['create_tarball'].setEnabled(has_single_selection)
229 self.menu_actions['reset_mixed'].setEnabled(has_single_selection)
230 self.menu_actions['reset_keep'].setEnabled(has_single_selection)
231 self.menu_actions['reset_merge'].setEnabled(has_single_selection)
232 self.menu_actions['reset_soft'].setEnabled(has_single_selection)
233 self.menu_actions['reset_hard'].setEnabled(has_single_selection)
234 self.menu_actions['restore_worktree'].setEnabled(has_single_selection)
235 self.menu_actions['revert'].setEnabled(has_single_selection)
236 self.menu_actions['save_blob'].setEnabled(has_single_selection)
238 def context_menu_event(self, event):
239 self.update_menu_actions(event)
240 menu = qtutils.create_menu(N_('Actions'), self)
241 menu.addAction(self.menu_actions['diff_this_selected'])
242 menu.addAction(self.menu_actions['diff_selected_this'])
243 menu.addAction(self.menu_actions['diff_commit'])
244 menu.addAction(self.menu_actions['diff_commit_all'])
245 menu.addSeparator()
246 menu.addAction(self.menu_actions['create_branch'])
247 menu.addAction(self.menu_actions['create_tag'])
248 menu.addSeparator()
249 menu.addAction(self.menu_actions['cherry_pick'])
250 menu.addAction(self.menu_actions['revert'])
251 menu.addAction(self.menu_actions['create_patch'])
252 menu.addAction(self.menu_actions['create_tarball'])
253 menu.addSeparator()
254 reset_menu = menu.addMenu(N_('Reset'))
255 reset_menu.addAction(self.menu_actions['reset_soft'])
256 reset_menu.addAction(self.menu_actions['reset_mixed'])
257 reset_menu.addAction(self.menu_actions['restore_worktree'])
258 reset_menu.addSeparator()
259 reset_menu.addAction(self.menu_actions['reset_keep'])
260 reset_menu.addAction(self.menu_actions['reset_merge'])
261 reset_menu.addAction(self.menu_actions['reset_hard'])
262 menu.addAction(self.menu_actions['checkout_detached'])
263 menu.addSeparator()
264 menu.addAction(self.menu_actions['save_blob'])
265 menu.addAction(self.menu_actions['copy'])
266 menu.exec_(self.mapToGlobal(event.pos()))
269 def set_icon(icon, action):
270 """"Set the icon for an action and return the action"""
271 action.setIcon(icon)
272 return action
275 def viewer_actions(widget):
276 return {
277 'diff_this_selected': set_icon(
278 icons.compare(),
279 qtutils.add_action(
280 widget, N_('Diff this -> selected'), widget.proxy.diff_this_selected
283 'diff_selected_this': set_icon(
284 icons.compare(),
285 qtutils.add_action(
286 widget, N_('Diff selected -> this'), widget.proxy.diff_selected_this
289 'create_branch': set_icon(
290 icons.branch(),
291 qtutils.add_action(widget, N_('Create Branch'), widget.proxy.create_branch),
293 'create_patch': set_icon(
294 icons.save(),
295 qtutils.add_action(widget, N_('Create Patch'), widget.proxy.create_patch),
297 'create_tag': set_icon(
298 icons.tag(),
299 qtutils.add_action(widget, N_('Create Tag'), widget.proxy.create_tag),
301 'create_tarball': set_icon(
302 icons.file_zip(),
303 qtutils.add_action(
304 widget, N_('Save As Tarball/Zip...'), widget.proxy.create_tarball
307 'cherry_pick': set_icon(
308 icons.style_dialog_apply(),
309 qtutils.add_action(widget, N_('Cherry Pick'), widget.proxy.cherry_pick),
311 'revert': set_icon(
312 icons.undo(), qtutils.add_action(widget, N_('Revert'), widget.proxy.revert)
314 'diff_commit': set_icon(
315 icons.diff(),
316 qtutils.add_action(
317 widget, N_('Launch Diff Tool'), widget.proxy.show_diff, hotkeys.DIFF
320 'diff_commit_all': set_icon(
321 icons.diff(),
322 qtutils.add_action(
323 widget,
324 N_('Launch Directory Diff Tool'),
325 widget.proxy.show_dir_diff,
326 hotkeys.DIFF_SECONDARY,
329 'checkout_detached': qtutils.add_action(
330 widget, N_('Checkout Detached HEAD'), widget.proxy.checkout_detached
332 'reset_soft': set_icon(
333 icons.style_dialog_reset(),
334 qtutils.add_action(
335 widget, N_('Reset Branch (Soft)'), widget.proxy.reset_soft
338 'reset_mixed': set_icon(
339 icons.style_dialog_reset(),
340 qtutils.add_action(
341 widget, N_('Reset Branch and Stage (Mixed)'), widget.proxy.reset_mixed
344 'reset_keep': set_icon(
345 icons.style_dialog_reset(),
346 qtutils.add_action(
347 widget,
348 N_('Restore Worktree and Reset All (Keep Unstaged Edits)'),
349 widget.proxy.reset_keep,
352 'reset_merge': set_icon(
353 icons.style_dialog_reset(),
354 qtutils.add_action(
355 widget,
356 N_('Restore Worktree and Reset All (Merge)'),
357 widget.proxy.reset_merge,
360 'reset_hard': set_icon(
361 icons.style_dialog_reset(),
362 qtutils.add_action(
363 widget,
364 N_('Restore Worktree and Reset All (Hard)'),
365 widget.proxy.reset_hard,
368 'restore_worktree': set_icon(
369 icons.edit(),
370 qtutils.add_action(
371 widget, N_('Restore Worktree'), widget.proxy.restore_worktree
374 'save_blob': set_icon(
375 icons.save(),
376 qtutils.add_action(
377 widget, N_('Grab File...'), widget.proxy.save_blob_dialog
380 'copy': set_icon(
381 icons.copy(),
382 qtutils.add_action(
383 widget,
384 N_('Copy SHA-1'),
385 widget.proxy.copy_to_clipboard,
386 hotkeys.COPY_SHA1,
392 class CommitTreeWidgetItem(QtWidgets.QTreeWidgetItem):
393 def __init__(self, commit, parent=None):
394 QtWidgets.QTreeWidgetItem.__init__(self, parent)
395 self.commit = commit
396 self.setText(0, commit.summary)
397 self.setText(1, commit.author)
398 self.setText(2, commit.authdate)
401 # pylint: disable=too-many-ancestors
402 class CommitTreeWidget(standard.TreeWidget, ViewerMixin):
404 diff_commits = Signal(object, object)
405 zoom_to_fit = Signal()
407 def __init__(self, context, notifier, parent):
408 standard.TreeWidget.__init__(self, parent)
409 ViewerMixin.__init__(self)
411 self.setSelectionMode(self.ExtendedSelection)
412 self.setHeaderLabels([N_('Summary'), N_('Author'), N_('Date, Time')])
414 self.context = context
415 self.oidmap = {}
416 self.menu_actions = None
417 self.notifier = notifier
418 self.selecting = False
419 self.commits = []
420 self._adjust_columns = False
422 self.action_up = qtutils.add_action(
423 self, N_('Go Up'), self.go_up, hotkeys.MOVE_UP
426 self.action_down = qtutils.add_action(
427 self, N_('Go Down'), self.go_down, hotkeys.MOVE_DOWN
430 self.zoom_to_fit_action = qtutils.add_action(
431 self, N_('Zoom to Fit'), self.zoom_to_fit.emit, hotkeys.FIT
434 notifier.add_observer(diff.COMMITS_SELECTED, self.commits_selected)
435 # pylint: disable=no-member
436 self.itemSelectionChanged.connect(self.selection_changed)
438 def export_state(self):
439 """Export the widget's state"""
440 # The base class method is intentionally overridden because we only
441 # care about the details below for this subwidget.
442 state = {}
443 state['column_widths'] = self.column_widths()
444 return state
446 def apply_state(self, state):
447 """Apply the exported widget state"""
448 try:
449 column_widths = state['column_widths']
450 except (KeyError, ValueError):
451 column_widths = None
452 if column_widths:
453 self.set_column_widths(column_widths)
454 else:
455 # Defer showing the columns until we are shown, and our true width
456 # is known. Calling adjust_columns() here ends up with the wrong
457 # answer because we have not yet been parented to the layout.
458 # We set this flag that we process once during our initial
459 # showEvent().
460 self._adjust_columns = True
461 return True
463 # Qt overrides
464 def showEvent(self, event):
465 """Override QWidget::showEvent() to size columns when we are shown"""
466 if self._adjust_columns:
467 self._adjust_columns = False
468 width = self.width()
469 two_thirds = (width * 2) // 3
470 one_sixth = width // 6
472 self.setColumnWidth(0, two_thirds)
473 self.setColumnWidth(1, one_sixth)
474 self.setColumnWidth(2, one_sixth)
475 return standard.TreeWidget.showEvent(self, event)
477 # ViewerMixin
478 def go_up(self):
479 self.goto(self.itemAbove)
481 def go_down(self):
482 self.goto(self.itemBelow)
484 def goto(self, finder):
485 items = self.selected_items()
486 item = items[0] if items else None
487 if item is None:
488 return
489 found = finder(item)
490 if found:
491 self.select([found.commit.oid])
493 def selected_commit_range(self):
494 selected_items = self.selected_items()
495 if not selected_items:
496 return None, None
497 return selected_items[-1].commit.oid, selected_items[0].commit.oid
499 def set_selecting(self, selecting):
500 self.selecting = selecting
502 def selection_changed(self):
503 items = self.selected_items()
504 if not items:
505 return
506 self.set_selecting(True)
507 self.notifier.notify_observers(diff.COMMITS_SELECTED, [i.commit for i in items])
508 self.set_selecting(False)
510 def commits_selected(self, commits):
511 if self.selecting:
512 return
513 with qtutils.BlockSignals(self):
514 self.select([commit.oid for commit in commits])
516 def select(self, oids):
517 if not oids:
518 return
519 self.clearSelection()
520 for oid in oids:
521 try:
522 item = self.oidmap[oid]
523 except KeyError:
524 continue
525 self.scrollToItem(item)
526 item.setSelected(True)
528 def clear(self):
529 QtWidgets.QTreeWidget.clear(self)
530 self.oidmap.clear()
531 self.commits = []
533 def add_commits(self, commits):
534 self.commits.extend(commits)
535 items = []
536 for c in reversed(commits):
537 item = CommitTreeWidgetItem(c)
538 items.append(item)
539 self.oidmap[c.oid] = item
540 for tag in c.tags:
541 self.oidmap[tag] = item
542 self.insertTopLevelItems(0, items)
544 def create_patch(self):
545 items = self.selectedItems()
546 if not items:
547 return
548 context = self.context
549 oids = [item.commit.oid for item in reversed(items)]
550 all_oids = [c.oid for c in self.commits]
551 cmds.do(cmds.FormatPatch, context, oids, all_oids)
553 # Qt overrides
554 def contextMenuEvent(self, event):
555 self.context_menu_event(event)
557 def mousePressEvent(self, event):
558 if event.button() == Qt.RightButton:
559 event.accept()
560 return
561 QtWidgets.QTreeWidget.mousePressEvent(self, event)
564 class GitDAG(standard.MainWindow):
565 """The git-dag widget."""
567 updated = Signal()
569 def __init__(self, context, params, parent=None):
570 super(GitDAG, self).__init__(parent)
572 self.setMinimumSize(420, 420)
574 # change when widgets are added/removed
575 self.widget_version = 2
576 self.context = context
577 self.params = params
578 self.model = context.model
580 self.commits = {}
581 self.commit_list = []
582 self.selection = []
583 self.old_refs = set()
584 self.old_oids = None
585 self.old_count = 0
586 self.force_refresh = False
588 self.thread = None
589 self.revtext = completion.GitLogLineEdit(context)
590 self.maxresults = standard.SpinBox()
592 self.zoom_out = qtutils.create_action_button(
593 tooltip=N_('Zoom Out'), icon=icons.zoom_out()
596 self.zoom_in = qtutils.create_action_button(
597 tooltip=N_('Zoom In'), icon=icons.zoom_in()
600 self.zoom_to_fit = qtutils.create_action_button(
601 tooltip=N_('Zoom to Fit'), icon=icons.zoom_fit_best()
604 self.notifier = notifier = observable.Observable()
605 self.notifier.refs_updated = refs_updated = 'refs_updated'
606 self.notifier.add_observer(refs_updated, self.display)
607 self.notifier.add_observer(filelist.HISTORIES_SELECTED, self.histories_selected)
608 self.notifier.add_observer(filelist.DIFFTOOL_SELECTED, self.difftool_selected)
609 self.notifier.add_observer(diff.COMMITS_SELECTED, self.commits_selected)
611 self.treewidget = CommitTreeWidget(context, notifier, self)
612 self.diffwidget = diff.DiffWidget(context, notifier, self, is_commit=True)
613 self.filewidget = filelist.FileWidget(context, notifier, self)
614 self.graphview = GraphView(context, notifier, self)
616 self.proxy = FocusRedirectProxy(
617 self.treewidget, self.graphview, self.filewidget
620 self.viewer_actions = actions = viewer_actions(self)
621 self.treewidget.menu_actions = actions
622 self.graphview.menu_actions = actions
624 self.controls_layout = qtutils.hbox(
625 defs.no_margin, defs.spacing, self.revtext, self.maxresults
628 self.controls_widget = QtWidgets.QWidget()
629 self.controls_widget.setLayout(self.controls_layout)
631 self.log_dock = qtutils.create_dock(N_('Log'), self, stretch=False)
632 self.log_dock.setWidget(self.treewidget)
633 log_dock_titlebar = self.log_dock.titleBarWidget()
634 log_dock_titlebar.add_corner_widget(self.controls_widget)
636 self.file_dock = qtutils.create_dock(N_('Files'), self)
637 self.file_dock.setWidget(self.filewidget)
639 self.diff_dock = qtutils.create_dock(N_('Diff'), self)
640 self.diff_dock.setWidget(self.diffwidget)
642 self.graph_controls_layout = qtutils.hbox(
643 defs.no_margin,
644 defs.button_spacing,
645 self.zoom_out,
646 self.zoom_in,
647 self.zoom_to_fit,
648 defs.spacing,
651 self.graph_controls_widget = QtWidgets.QWidget()
652 self.graph_controls_widget.setLayout(self.graph_controls_layout)
654 self.graphview_dock = qtutils.create_dock(N_('Graph'), self)
655 self.graphview_dock.setWidget(self.graphview)
656 graph_titlebar = self.graphview_dock.titleBarWidget()
657 graph_titlebar.add_corner_widget(self.graph_controls_widget)
659 self.lock_layout_action = qtutils.add_action_bool(
660 self, N_('Lock Layout'), self.set_lock_layout, False
663 self.refresh_action = qtutils.add_action(
664 self, N_('Refresh'), self.refresh, hotkeys.REFRESH
667 # Create the application menu
668 self.menubar = QtWidgets.QMenuBar(self)
669 self.setMenuBar(self.menubar)
671 # View Menu
672 self.view_menu = qtutils.add_menu(N_('View'), self.menubar)
673 self.view_menu.addAction(self.refresh_action)
674 self.view_menu.addAction(self.log_dock.toggleViewAction())
675 self.view_menu.addAction(self.graphview_dock.toggleViewAction())
676 self.view_menu.addAction(self.diff_dock.toggleViewAction())
677 self.view_menu.addAction(self.file_dock.toggleViewAction())
678 self.view_menu.addSeparator()
679 self.view_menu.addAction(self.lock_layout_action)
681 left = Qt.LeftDockWidgetArea
682 right = Qt.RightDockWidgetArea
683 self.addDockWidget(left, self.log_dock)
684 self.addDockWidget(left, self.diff_dock)
685 self.addDockWidget(right, self.graphview_dock)
686 self.addDockWidget(right, self.file_dock)
688 # Also re-loads dag.* from the saved state
689 self.init_state(context.settings, self.resize_to_desktop)
691 qtutils.connect_button(self.zoom_out, self.graphview.zoom_out)
692 qtutils.connect_button(self.zoom_in, self.graphview.zoom_in)
693 qtutils.connect_button(self.zoom_to_fit, self.graphview.zoom_to_fit)
695 self.treewidget.zoom_to_fit.connect(self.graphview.zoom_to_fit)
696 self.treewidget.diff_commits.connect(self.diff_commits)
697 self.graphview.diff_commits.connect(self.diff_commits)
698 self.filewidget.grab_file.connect(self.grab_file)
700 # pylint: disable=no-member
701 self.maxresults.editingFinished.connect(self.display)
703 self.revtext.textChanged.connect(self.text_changed)
704 self.revtext.activated.connect(self.display)
705 self.revtext.enter.connect(self.display)
706 self.revtext.down.connect(self.focus_tree)
708 # The model is updated in another thread so use
709 # signals/slots to bring control back to the main GUI thread
710 self.model.add_observer(self.model.message_updated, self.updated.emit)
711 self.updated.connect(self.model_updated, type=Qt.QueuedConnection)
713 qtutils.add_action(self, 'Focus', self.focus_input, hotkeys.FOCUS)
714 qtutils.add_close_action(self)
716 self.set_params(params)
718 def set_params(self, params):
719 context = self.context
720 self.params = params
722 # Update fields affected by model
723 self.revtext.setText(params.ref)
724 self.maxresults.setValue(params.count)
725 self.update_window_title()
727 if self.thread is not None:
728 self.thread.stop()
730 self.thread = ReaderThread(context, params, self)
732 thread = self.thread
733 thread.begin.connect(self.thread_begin, type=Qt.QueuedConnection)
734 thread.status.connect(self.thread_status, type=Qt.QueuedConnection)
735 thread.add.connect(self.add_commits, type=Qt.QueuedConnection)
736 thread.end.connect(self.thread_end, type=Qt.QueuedConnection)
738 def focus_input(self):
739 self.revtext.setFocus()
741 def focus_tree(self):
742 self.treewidget.setFocus()
744 def text_changed(self, txt):
745 self.params.ref = txt
746 self.update_window_title()
748 def update_window_title(self):
749 project = self.model.project
750 if self.params.ref:
751 self.setWindowTitle(
752 N_('%(project)s: %(ref)s - DAG')
753 % dict(project=project, ref=self.params.ref)
755 else:
756 self.setWindowTitle(project + N_(' - DAG'))
758 def export_state(self):
759 state = standard.MainWindow.export_state(self)
760 state['count'] = self.params.count
761 state['log'] = self.treewidget.export_state()
762 return state
764 def apply_state(self, state):
765 result = standard.MainWindow.apply_state(self, state)
766 try:
767 count = state['count']
768 if self.params.overridden('count'):
769 count = self.params.count
770 except (KeyError, TypeError, ValueError, AttributeError):
771 count = self.params.count
772 result = False
773 self.params.set_count(count)
774 self.lock_layout_action.setChecked(state.get('lock_layout', False))
776 try:
777 log_state = state['log']
778 except (KeyError, ValueError):
779 log_state = None
780 if log_state:
781 self.treewidget.apply_state(log_state)
783 return result
785 def model_updated(self):
786 self.display()
787 self.update_window_title()
789 def refresh(self):
790 """Unconditionally refresh the DAG"""
791 # self.force_refresh triggers an Unconditional redraw
792 self.force_refresh = True
793 cmds.do(cmds.Refresh, self.context)
794 self.force_refresh = False
796 def display(self):
797 """Update the view when the Git refs change"""
798 ref = get(self.revtext)
799 count = get(self.maxresults)
800 context = self.context
801 model = self.model
802 # The DAG tries to avoid updating when the object IDs have not
803 # changed. Without doing this the DAG constantly redraws itself
804 # whenever inotify sends update events, which hurts usability.
806 # To minimize redraws we leverage `git rev-parse`. The strategy is to
807 # use `git rev-parse` on the input line, which converts each argument
808 # into object IDs. From there it's a simple matter of detecting when
809 # the object IDs changed.
811 # In addition to object IDs, we also need to know when the set of
812 # named references (branches, tags) changes so that an update is
813 # triggered when new branches and tags are created.
814 refs = set(model.local_branches + model.remote_branches + model.tags)
815 argv = utils.shell_split(ref or 'HEAD')
816 oids = gitcmds.parse_refs(context, argv)
817 update = (
818 self.force_refresh
819 or count != self.old_count
820 or oids != self.old_oids
821 or refs != self.old_refs
823 if update:
824 self.thread.stop()
825 self.params.set_ref(ref)
826 self.params.set_count(count)
827 self.thread.start()
829 self.old_oids = oids
830 self.old_count = count
831 self.old_refs = refs
833 def commits_selected(self, commits):
834 if commits:
835 self.selection = commits
837 def clear(self):
838 self.commits.clear()
839 self.commit_list = []
840 self.graphview.clear()
841 self.treewidget.clear()
843 def add_commits(self, commits):
844 self.commit_list.extend(commits)
845 # Keep track of commits
846 for commit_obj in commits:
847 self.commits[commit_obj.oid] = commit_obj
848 for tag in commit_obj.tags:
849 self.commits[tag] = commit_obj
850 self.graphview.add_commits(commits)
851 self.treewidget.add_commits(commits)
853 def thread_begin(self):
854 self.clear()
856 def thread_end(self):
857 self.restore_selection()
859 def thread_status(self, successful):
860 self.revtext.hint.set_error(not successful)
862 def restore_selection(self):
863 selection = self.selection
864 try:
865 commit_obj = self.commit_list[-1]
866 except IndexError:
867 # No commits, exist, early-out
868 return
870 new_commits = [self.commits.get(s.oid, None) for s in selection]
871 new_commits = [c for c in new_commits if c is not None]
872 if new_commits:
873 # The old selection exists in the new state
874 self.notifier.notify_observers(diff.COMMITS_SELECTED, new_commits)
875 else:
876 # The old selection is now empty. Select the top-most commit
877 self.notifier.notify_observers(diff.COMMITS_SELECTED, [commit_obj])
879 self.graphview.set_initial_view()
881 def diff_commits(self, a, b):
882 paths = self.params.paths()
883 if paths:
884 cmds.difftool_launch(self.context, left=a, right=b, paths=paths)
885 else:
886 difftool.diff_commits(self.context, self, a, b)
888 # Qt overrides
889 def closeEvent(self, event):
890 self.revtext.close_popup()
891 self.thread.stop()
892 standard.MainWindow.closeEvent(self, event)
894 def histories_selected(self, histories):
895 argv = [self.model.currentbranch, '--']
896 argv.extend(histories)
897 text = core.list2cmdline(argv)
898 self.revtext.setText(text)
899 self.display()
901 def difftool_selected(self, files):
902 bottom, top = self.treewidget.selected_commit_range()
903 if not top:
904 return
905 cmds.difftool_launch(
906 self.context, left=bottom, left_take_parent=True, right=top, paths=files
909 def grab_file(self, filename):
910 """Save the selected file from the filelist widget"""
911 oid = self.treewidget.selected_oid()
912 model = browse.BrowseModel(oid, filename=filename)
913 browse.save_path(self.context, filename, model)
916 class ReaderThread(QtCore.QThread):
917 begin = Signal()
918 add = Signal(object)
919 end = Signal()
920 status = Signal(object)
922 def __init__(self, context, params, parent):
923 QtCore.QThread.__init__(self, parent)
924 self.context = context
925 self.params = params
926 self._abort = False
927 self._stop = False
928 self._mutex = QtCore.QMutex()
929 self._condition = QtCore.QWaitCondition()
931 def run(self):
932 context = self.context
933 repo = dag.RepoReader(context, self.params)
934 repo.reset()
935 self.begin.emit()
936 commits = []
937 for c in repo.get():
938 self._mutex.lock()
939 if self._stop:
940 self._condition.wait(self._mutex)
941 self._mutex.unlock()
942 if self._abort:
943 repo.reset()
944 return
945 commits.append(c)
946 if len(commits) >= 512:
947 self.add.emit(commits)
948 commits = []
950 self.status.emit(repo.returncode == 0)
951 if commits:
952 self.add.emit(commits)
953 self.end.emit()
955 def start(self):
956 self._abort = False
957 self._stop = False
958 QtCore.QThread.start(self)
960 def pause(self):
961 self._mutex.lock()
962 self._stop = True
963 self._mutex.unlock()
965 def resume(self):
966 self._mutex.lock()
967 self._stop = False
968 self._mutex.unlock()
969 self._condition.wakeOne()
971 def stop(self):
972 self._abort = True
973 self.wait()
976 class Cache(object):
978 _label_font = None
980 @classmethod
981 def label_font(cls):
982 font = cls._label_font
983 if font is None:
984 font = cls._label_font = QtWidgets.QApplication.font()
985 font.setPointSize(6)
986 return font
989 class Edge(QtWidgets.QGraphicsItem):
990 item_type = QtWidgets.QGraphicsItem.UserType + 1
992 def __init__(self, source, dest):
994 QtWidgets.QGraphicsItem.__init__(self)
996 self.setAcceptedMouseButtons(Qt.NoButton)
997 self.source = source
998 self.dest = dest
999 self.commit = source.commit
1000 self.setZValue(-2)
1002 self.recompute_bound()
1003 self.path = None
1004 self.path_valid = False
1006 # Choose a new color for new branch edges
1007 if self.source.x() < self.dest.x():
1008 color = EdgeColor.cycle()
1009 line = Qt.SolidLine
1010 elif self.source.x() != self.dest.x():
1011 color = EdgeColor.current()
1012 line = Qt.SolidLine
1013 else:
1014 color = EdgeColor.current()
1015 line = Qt.SolidLine
1017 self.pen = QtGui.QPen(color, 4.0, line, Qt.SquareCap, Qt.RoundJoin)
1019 def recompute_bound(self):
1020 dest_pt = Commit.item_bbox.center()
1022 self.source_pt = self.mapFromItem(self.source, dest_pt)
1023 self.dest_pt = self.mapFromItem(self.dest, dest_pt)
1024 self.line = QtCore.QLineF(self.source_pt, self.dest_pt)
1026 width = self.dest_pt.x() - self.source_pt.x()
1027 height = self.dest_pt.y() - self.source_pt.y()
1028 rect = QtCore.QRectF(self.source_pt, QtCore.QSizeF(width, height))
1029 self.bound = rect.normalized()
1031 def commits_were_invalidated(self):
1032 self.recompute_bound()
1033 self.prepareGeometryChange()
1034 # The path should not be recomputed immediately because just small part
1035 # of DAG is actually shown at same time. It will be recomputed on
1036 # demand in course of 'paint' method.
1037 self.path_valid = False
1038 # Hence, just queue redrawing.
1039 self.update()
1041 # Qt overrides
1042 def type(self):
1043 return self.item_type
1045 def boundingRect(self):
1046 return self.bound
1048 def recompute_path(self):
1049 QRectF = QtCore.QRectF
1050 QPointF = QtCore.QPointF
1052 arc_rect = 10
1053 connector_length = 5
1055 path = QtGui.QPainterPath()
1057 if self.source.x() == self.dest.x():
1058 path.moveTo(self.source.x(), self.source.y())
1059 path.lineTo(self.dest.x(), self.dest.y())
1060 else:
1061 # Define points starting from source
1062 point1 = QPointF(self.source.x(), self.source.y())
1063 point2 = QPointF(point1.x(), point1.y() - connector_length)
1064 point3 = QPointF(point2.x() + arc_rect, point2.y() - arc_rect)
1066 # Define points starting from dest
1067 point4 = QPointF(self.dest.x(), self.dest.y())
1068 point5 = QPointF(point4.x(), point3.y() - arc_rect)
1069 point6 = QPointF(point5.x() - arc_rect, point5.y() + arc_rect)
1071 start_angle_arc1 = 180
1072 span_angle_arc1 = 90
1073 start_angle_arc2 = 90
1074 span_angle_arc2 = -90
1076 # If the dest is at the left of the source, then we
1077 # need to reverse some values
1078 if self.source.x() > self.dest.x():
1079 point3 = QPointF(point2.x() - arc_rect, point3.y())
1080 point6 = QPointF(point5.x() + arc_rect, point6.y())
1082 span_angle_arc1 = 90
1084 path.moveTo(point1)
1085 path.lineTo(point2)
1086 path.arcTo(QRectF(point2, point3), start_angle_arc1, span_angle_arc1)
1087 path.lineTo(point6)
1088 path.arcTo(QRectF(point6, point5), start_angle_arc2, span_angle_arc2)
1089 path.lineTo(point4)
1091 self.path = path
1092 self.path_valid = True
1094 def paint(self, painter, _option, _widget):
1095 if not self.path_valid:
1096 self.recompute_path()
1097 painter.setPen(self.pen)
1098 painter.drawPath(self.path)
1101 class EdgeColor(object):
1102 """An edge color factory"""
1104 current_color_index = 0
1105 colors = [
1106 QtGui.QColor(Qt.red),
1107 QtGui.QColor(Qt.green),
1108 QtGui.QColor(Qt.blue),
1109 QtGui.QColor(Qt.black),
1110 QtGui.QColor(Qt.darkRed),
1111 QtGui.QColor(Qt.darkGreen),
1112 QtGui.QColor(Qt.darkBlue),
1113 QtGui.QColor(Qt.cyan),
1114 QtGui.QColor(Qt.magenta),
1115 # Orange; Qt.yellow is too low-contrast
1116 qtutils.rgba(0xFF, 0x66, 0x00),
1117 QtGui.QColor(Qt.gray),
1118 QtGui.QColor(Qt.darkCyan),
1119 QtGui.QColor(Qt.darkMagenta),
1120 QtGui.QColor(Qt.darkYellow),
1121 QtGui.QColor(Qt.darkGray),
1124 @classmethod
1125 def cycle(cls):
1126 cls.current_color_index += 1
1127 cls.current_color_index %= len(cls.colors)
1128 color = cls.colors[cls.current_color_index]
1129 color.setAlpha(128)
1130 return color
1132 @classmethod
1133 def current(cls):
1134 return cls.colors[cls.current_color_index]
1136 @classmethod
1137 def reset(cls):
1138 cls.current_color_index = 0
1141 class Commit(QtWidgets.QGraphicsItem):
1142 item_type = QtWidgets.QGraphicsItem.UserType + 2
1143 commit_radius = 12.0
1144 merge_radius = 18.0
1146 item_shape = QtGui.QPainterPath()
1147 item_shape.addRect(
1148 commit_radius / -2.0, commit_radius / -2.0, commit_radius, commit_radius
1150 item_bbox = item_shape.boundingRect()
1152 inner_rect = QtGui.QPainterPath()
1153 inner_rect.addRect(
1154 commit_radius / -2.0 + 2.0,
1155 commit_radius / -2.0 + 2.0,
1156 commit_radius - 4.0,
1157 commit_radius - 4.0,
1159 inner_rect = inner_rect.boundingRect()
1161 commit_color = QtGui.QColor(Qt.white)
1162 outline_color = commit_color.darker()
1163 merge_color = QtGui.QColor(Qt.lightGray)
1165 commit_selected_color = QtGui.QColor(Qt.green)
1166 selected_outline_color = commit_selected_color.darker()
1168 commit_pen = QtGui.QPen()
1169 commit_pen.setWidth(1)
1170 commit_pen.setColor(outline_color)
1172 def __init__(
1173 self,
1174 commit,
1175 notifier,
1176 selectable=QtWidgets.QGraphicsItem.ItemIsSelectable,
1177 cursor=Qt.PointingHandCursor,
1178 xpos=commit_radius / 2.0 + 1.0,
1179 cached_commit_color=commit_color,
1180 cached_merge_color=merge_color,
1183 QtWidgets.QGraphicsItem.__init__(self)
1185 self.commit = commit
1186 self.notifier = notifier
1187 self.selected = False
1189 self.setZValue(0)
1190 self.setFlag(selectable)
1191 self.setCursor(cursor)
1192 self.setToolTip(commit.oid[:12] + ': ' + commit.summary)
1194 if commit.tags:
1195 self.label = label = Label(commit)
1196 label.setParentItem(self)
1197 label.setPos(xpos + 1, -self.commit_radius / 2.0)
1198 else:
1199 self.label = None
1201 if len(commit.parents) > 1:
1202 self.brush = cached_merge_color
1203 else:
1204 self.brush = cached_commit_color
1206 self.pressed = False
1207 self.dragged = False
1209 self.edges = {}
1211 def blockSignals(self, blocked):
1212 self.notifier.notification_enabled = not blocked
1214 def itemChange(self, change, value):
1215 if change == QtWidgets.QGraphicsItem.ItemSelectedHasChanged:
1216 # Broadcast selection to other widgets
1217 selected_items = self.scene().selectedItems()
1218 commits = [item.commit for item in selected_items]
1219 self.scene().parent().set_selecting(True)
1220 self.notifier.notify_observers(diff.COMMITS_SELECTED, commits)
1221 self.scene().parent().set_selecting(False)
1223 # Cache the pen for use in paint()
1224 if value:
1225 self.brush = self.commit_selected_color
1226 color = self.selected_outline_color
1227 else:
1228 if len(self.commit.parents) > 1:
1229 self.brush = self.merge_color
1230 else:
1231 self.brush = self.commit_color
1232 color = self.outline_color
1233 commit_pen = QtGui.QPen()
1234 commit_pen.setWidth(1.0)
1235 commit_pen.setColor(color)
1236 self.commit_pen = commit_pen
1238 return QtWidgets.QGraphicsItem.itemChange(self, change, value)
1240 def type(self):
1241 return self.item_type
1243 def boundingRect(self):
1244 return self.item_bbox
1246 def shape(self):
1247 return self.item_shape
1249 def paint(self, painter, option, _widget):
1251 # Do not draw outside the exposed rect
1252 painter.setClipRect(option.exposedRect)
1254 # Draw ellipse
1255 painter.setPen(self.commit_pen)
1256 painter.setBrush(self.brush)
1257 painter.drawEllipse(self.inner_rect)
1259 def mousePressEvent(self, event):
1260 QtWidgets.QGraphicsItem.mousePressEvent(self, event)
1261 self.pressed = True
1262 self.selected = self.isSelected()
1264 def mouseMoveEvent(self, event):
1265 if self.pressed:
1266 self.dragged = True
1267 QtWidgets.QGraphicsItem.mouseMoveEvent(self, event)
1269 def mouseReleaseEvent(self, event):
1270 QtWidgets.QGraphicsItem.mouseReleaseEvent(self, event)
1271 if not self.dragged and self.selected and event.button() == Qt.LeftButton:
1272 return
1273 self.pressed = False
1274 self.dragged = False
1277 class Label(QtWidgets.QGraphicsItem):
1279 item_type = QtWidgets.QGraphicsItem.UserType + 3
1281 head_color = QtGui.QColor(Qt.green)
1282 other_color = QtGui.QColor(Qt.white)
1283 remote_color = QtGui.QColor(Qt.yellow)
1285 head_pen = QtGui.QPen()
1286 head_pen.setColor(head_color.darker().darker())
1287 head_pen.setWidth(1)
1289 text_pen = QtGui.QPen()
1290 text_pen.setColor(QtGui.QColor(Qt.darkGray))
1291 text_pen.setWidth(1)
1293 alpha = 180
1294 head_color.setAlpha(alpha)
1295 other_color.setAlpha(alpha)
1296 remote_color.setAlpha(alpha)
1298 border = 2
1299 item_spacing = 5
1300 text_offset = 1
1302 def __init__(self, commit):
1303 QtWidgets.QGraphicsItem.__init__(self)
1304 self.setZValue(-1)
1305 self.commit = commit
1307 def type(self):
1308 return self.item_type
1310 def boundingRect(self, cache=Cache):
1311 QPainterPath = QtGui.QPainterPath
1312 QRectF = QtCore.QRectF
1314 width = 72
1315 height = 18
1316 current_width = 0
1317 spacing = self.item_spacing
1318 border = self.border + self.text_offset # text offset=1 in paint()
1320 font = cache.label_font()
1321 item_shape = QPainterPath()
1323 base_rect = QRectF(0, 0, width, height)
1324 base_rect = base_rect.adjusted(-border, -border, border, border)
1325 item_shape.addRect(base_rect)
1327 for tag in self.commit.tags:
1328 text_shape = QPainterPath()
1329 text_shape.addText(current_width, 0, font, tag)
1330 text_rect = text_shape.boundingRect()
1331 box_rect = text_rect.adjusted(-border, -border, border, border)
1332 item_shape.addRect(box_rect)
1333 current_width = item_shape.boundingRect().width() + spacing
1335 return item_shape.boundingRect()
1337 def paint(self, painter, _option, _widget, cache=Cache):
1338 # Draw tags and branches
1339 font = cache.label_font()
1340 painter.setFont(font)
1342 current_width = 0
1343 border = self.border
1344 offset = self.text_offset
1345 spacing = self.item_spacing
1346 QRectF = QtCore.QRectF
1348 HEAD = 'HEAD'
1349 remotes_prefix = 'remotes/'
1350 tags_prefix = 'tags/'
1351 heads_prefix = 'heads/'
1352 remotes_len = len(remotes_prefix)
1353 tags_len = len(tags_prefix)
1354 heads_len = len(heads_prefix)
1356 for tag in self.commit.tags:
1357 if tag == HEAD:
1358 painter.setPen(self.text_pen)
1359 painter.setBrush(self.remote_color)
1360 elif tag.startswith(remotes_prefix):
1361 tag = tag[remotes_len:]
1362 painter.setPen(self.text_pen)
1363 painter.setBrush(self.other_color)
1364 elif tag.startswith(tags_prefix):
1365 tag = tag[tags_len:]
1366 painter.setPen(self.text_pen)
1367 painter.setBrush(self.remote_color)
1368 elif tag.startswith(heads_prefix):
1369 tag = tag[heads_len:]
1370 painter.setPen(self.head_pen)
1371 painter.setBrush(self.head_color)
1372 else:
1373 painter.setPen(self.text_pen)
1374 painter.setBrush(self.other_color)
1376 text_rect = painter.boundingRect(
1377 QRectF(current_width, 0, 0, 0), Qt.TextSingleLine, tag
1379 box_rect = text_rect.adjusted(-offset, -offset, offset, offset)
1381 painter.drawRoundedRect(box_rect, border, border)
1382 painter.drawText(text_rect, Qt.TextSingleLine, tag)
1383 current_width += text_rect.width() + spacing
1386 # pylint: disable=too-many-ancestors
1387 class GraphView(QtWidgets.QGraphicsView, ViewerMixin):
1389 diff_commits = Signal(object, object)
1391 x_adjust = int(Commit.commit_radius * 4 / 3)
1392 y_adjust = int(Commit.commit_radius * 4 / 3)
1394 x_off = -18
1395 y_off = -24
1397 def __init__(self, context, notifier, parent):
1398 QtWidgets.QGraphicsView.__init__(self, parent)
1399 ViewerMixin.__init__(self)
1401 highlight = self.palette().color(QtGui.QPalette.Highlight)
1402 Commit.commit_selected_color = highlight
1403 Commit.selected_outline_color = highlight.darker()
1405 self.context = context
1406 self.columns = {}
1407 self.selection_list = []
1408 self.menu_actions = None
1409 self.notifier = notifier
1410 self.commits = []
1411 self.items = {}
1412 self.mouse_start = [0, 0]
1413 self.saved_matrix = self.transform()
1414 self.max_column = 0
1415 self.min_column = 0
1416 self.frontier = {}
1417 self.tagged_cells = set()
1419 self.x_start = 24
1420 self.x_min = 24
1421 self.x_offsets = collections.defaultdict(lambda: self.x_min)
1423 self.is_panning = False
1424 self.pressed = False
1425 self.selecting = False
1426 self.last_mouse = [0, 0]
1427 self.zoom = 2
1428 self.setDragMode(self.RubberBandDrag)
1430 scene = QtWidgets.QGraphicsScene(self)
1431 scene.setItemIndexMethod(QtWidgets.QGraphicsScene.NoIndex)
1432 self.setScene(scene)
1434 self.setRenderHint(QtGui.QPainter.Antialiasing)
1435 self.setViewportUpdateMode(self.BoundingRectViewportUpdate)
1436 self.setCacheMode(QtWidgets.QGraphicsView.CacheBackground)
1437 self.setTransformationAnchor(QtWidgets.QGraphicsView.AnchorUnderMouse)
1438 self.setResizeAnchor(QtWidgets.QGraphicsView.NoAnchor)
1439 self.setBackgroundBrush(QtGui.QColor(Qt.white))
1441 qtutils.add_action(
1442 self,
1443 N_('Zoom In'),
1444 self.zoom_in,
1445 hotkeys.ZOOM_IN,
1446 hotkeys.ZOOM_IN_SECONDARY,
1449 qtutils.add_action(self, N_('Zoom Out'), self.zoom_out, hotkeys.ZOOM_OUT)
1451 qtutils.add_action(self, N_('Zoom to Fit'), self.zoom_to_fit, hotkeys.FIT)
1453 qtutils.add_action(
1454 self, N_('Select Parent'), self._select_parent, hotkeys.MOVE_DOWN_TERTIARY
1457 qtutils.add_action(
1458 self,
1459 N_('Select Oldest Parent'),
1460 self._select_oldest_parent,
1461 hotkeys.MOVE_DOWN,
1464 qtutils.add_action(
1465 self, N_('Select Child'), self._select_child, hotkeys.MOVE_UP_TERTIARY
1468 qtutils.add_action(
1469 self, N_('Select Newest Child'), self._select_newest_child, hotkeys.MOVE_UP
1472 notifier.add_observer(diff.COMMITS_SELECTED, self.commits_selected)
1474 def clear(self):
1475 EdgeColor.reset()
1476 self.scene().clear()
1477 self.selection_list = []
1478 self.items.clear()
1479 self.x_offsets.clear()
1480 self.x_min = 24
1481 self.commits = []
1483 # ViewerMixin interface
1484 def selected_items(self):
1485 """Return the currently selected items"""
1486 return self.scene().selectedItems()
1488 def zoom_in(self):
1489 self.scale_view(1.5)
1491 def zoom_out(self):
1492 self.scale_view(1.0 / 1.5)
1494 def commits_selected(self, commits):
1495 if self.selecting:
1496 return
1497 self.select([commit.oid for commit in commits])
1499 def select(self, oids):
1500 """Select the item for the oids"""
1501 self.scene().clearSelection()
1502 for oid in oids:
1503 try:
1504 item = self.items[oid]
1505 except KeyError:
1506 continue
1507 item.blockSignals(True)
1508 item.setSelected(True)
1509 item.blockSignals(False)
1510 item_rect = item.sceneTransform().mapRect(item.boundingRect())
1511 self.ensureVisible(item_rect)
1513 def _get_item_by_generation(self, commits, criteria_fn):
1514 """Return the item for the commit matching criteria"""
1515 if not commits:
1516 return None
1517 generation = None
1518 for commit in commits:
1519 if generation is None or criteria_fn(generation, commit.generation):
1520 oid = commit.oid
1521 generation = commit.generation
1522 try:
1523 return self.items[oid]
1524 except KeyError:
1525 return None
1527 def _oldest_item(self, commits):
1528 """Return the item for the commit with the oldest generation number"""
1529 return self._get_item_by_generation(commits, lambda a, b: a > b)
1531 def _newest_item(self, commits):
1532 """Return the item for the commit with the newest generation number"""
1533 return self._get_item_by_generation(commits, lambda a, b: a < b)
1535 def create_patch(self):
1536 items = self.selected_items()
1537 if not items:
1538 return
1539 context = self.context
1540 selected_commits = sort_by_generation([n.commit for n in items])
1541 oids = [c.oid for c in selected_commits]
1542 all_oids = [c.oid for c in self.commits]
1543 cmds.do(cmds.FormatPatch, context, oids, all_oids)
1545 def _select_parent(self):
1546 """Select the parent with the newest generation number"""
1547 selected_item = self.selected_item()
1548 if selected_item is None:
1549 return
1550 parent_item = self._newest_item(selected_item.commit.parents)
1551 if parent_item is None:
1552 return
1553 selected_item.setSelected(False)
1554 parent_item.setSelected(True)
1555 self.ensureVisible(parent_item.mapRectToScene(parent_item.boundingRect()))
1557 def _select_oldest_parent(self):
1558 """Select the parent with the oldest generation number"""
1559 selected_item = self.selected_item()
1560 if selected_item is None:
1561 return
1562 parent_item = self._oldest_item(selected_item.commit.parents)
1563 if parent_item is None:
1564 return
1565 selected_item.setSelected(False)
1566 parent_item.setSelected(True)
1567 scene_rect = parent_item.mapRectToScene(parent_item.boundingRect())
1568 self.ensureVisible(scene_rect)
1570 def _select_child(self):
1571 """Select the child with the oldest generation number"""
1572 selected_item = self.selected_item()
1573 if selected_item is None:
1574 return
1575 child_item = self._oldest_item(selected_item.commit.children)
1576 if child_item is None:
1577 return
1578 selected_item.setSelected(False)
1579 child_item.setSelected(True)
1580 scene_rect = child_item.mapRectToScene(child_item.boundingRect())
1581 self.ensureVisible(scene_rect)
1583 def _select_newest_child(self):
1584 """Select the Nth child with the newest generation number (N > 1)"""
1585 selected_item = self.selected_item()
1586 if selected_item is None:
1587 return
1588 if len(selected_item.commit.children) > 1:
1589 children = selected_item.commit.children[1:]
1590 else:
1591 children = selected_item.commit.children
1592 child_item = self._newest_item(children)
1593 if child_item is None:
1594 return
1595 selected_item.setSelected(False)
1596 child_item.setSelected(True)
1597 scene_rect = child_item.mapRectToScene(child_item.boundingRect())
1598 self.ensureVisible(scene_rect)
1600 def set_initial_view(self):
1601 items = []
1602 selected = self.selected_items()
1603 if selected:
1604 items.extend(selected)
1606 if not selected and self.commits:
1607 commit = self.commits[-1]
1608 items.append(self.items[commit.oid])
1610 self.setSceneRect(self.scene().itemsBoundingRect())
1611 self.fit_view_to_items(items)
1613 def zoom_to_fit(self):
1614 """Fit selected items into the viewport"""
1616 items = self.selected_items()
1617 self.fit_view_to_items(items)
1619 def fit_view_to_items(self, items):
1620 if not items:
1621 rect = self.scene().itemsBoundingRect()
1622 else:
1623 x_min = y_min = maxsize
1624 x_max = y_max = -maxsize
1626 for item in items:
1627 pos = item.pos()
1628 x = pos.x()
1629 y = pos.y()
1630 x_min = min(x_min, x)
1631 x_max = max(x_max, x)
1632 y_min = min(y_min, y)
1633 y_max = max(y_max, y)
1635 rect = QtCore.QRectF(x_min, y_min, abs(x_max - x_min), abs(y_max - y_min))
1637 x_adjust = abs(GraphView.x_adjust)
1638 y_adjust = abs(GraphView.y_adjust)
1640 count = max(2.0, 10.0 - len(items) / 2.0)
1641 y_offset = int(y_adjust * count)
1642 x_offset = int(x_adjust * count)
1643 rect.setX(rect.x() - x_offset // 2)
1644 rect.setY(rect.y() - y_adjust // 2)
1645 rect.setHeight(rect.height() + y_offset)
1646 rect.setWidth(rect.width() + x_offset)
1648 self.fitInView(rect, Qt.KeepAspectRatio)
1649 self.scene().invalidate()
1651 def save_selection(self, event):
1652 if event.button() != Qt.LeftButton:
1653 return
1654 elif Qt.ShiftModifier != event.modifiers():
1655 return
1656 self.selection_list = self.selected_items()
1658 def restore_selection(self, event):
1659 if Qt.ShiftModifier != event.modifiers():
1660 return
1661 for item in self.selection_list:
1662 item.setSelected(True)
1664 def handle_event(self, event_handler, event):
1665 self.save_selection(event)
1666 event_handler(self, event)
1667 self.restore_selection(event)
1668 self.update()
1670 def set_selecting(self, selecting):
1671 self.selecting = selecting
1673 def pan(self, event):
1674 pos = event.pos()
1675 dx = pos.x() - self.mouse_start[0]
1676 dy = pos.y() - self.mouse_start[1]
1678 if dx == 0 and dy == 0:
1679 return
1681 rect = QtCore.QRect(0, 0, abs(dx), abs(dy))
1682 delta = self.mapToScene(rect).boundingRect()
1684 tx = delta.width()
1685 if dx < 0.0:
1686 tx = -tx
1688 ty = delta.height()
1689 if dy < 0.0:
1690 ty = -ty
1692 matrix = self.transform()
1693 matrix.reset()
1694 matrix *= self.saved_matrix
1695 matrix.translate(tx, ty)
1697 self.setTransformationAnchor(QtWidgets.QGraphicsView.NoAnchor)
1698 self.setTransform(matrix)
1700 def wheel_zoom(self, event):
1701 """Handle mouse wheel zooming."""
1702 delta = qtcompat.wheel_delta(event)
1703 zoom = math.pow(2.0, delta / 512.0)
1704 factor = (
1705 self.transform()
1706 .scale(zoom, zoom)
1707 .mapRect(QtCore.QRectF(0.0, 0.0, 1.0, 1.0))
1708 .width()
1710 if factor < 0.014 or factor > 42.0:
1711 return
1712 self.setTransformationAnchor(QtWidgets.QGraphicsView.AnchorUnderMouse)
1713 self.zoom = zoom
1714 self.scale(zoom, zoom)
1716 def wheel_pan(self, event):
1717 """Handle mouse wheel panning."""
1718 unit = QtCore.QRectF(0.0, 0.0, 1.0, 1.0)
1719 factor = 1.0 / self.transform().mapRect(unit).width()
1720 tx, ty = qtcompat.wheel_translation(event)
1722 matrix = self.transform().translate(tx * factor, ty * factor)
1723 self.setTransformationAnchor(QtWidgets.QGraphicsView.NoAnchor)
1724 self.setTransform(matrix)
1726 def scale_view(self, scale):
1727 factor = (
1728 self.transform()
1729 .scale(scale, scale)
1730 .mapRect(QtCore.QRectF(0, 0, 1, 1))
1731 .width()
1733 if factor < 0.07 or factor > 100.0:
1734 return
1735 self.zoom = scale
1737 adjust_scrollbars = True
1738 scrollbar = self.verticalScrollBar()
1739 if scrollbar:
1740 value = get(scrollbar)
1741 min_ = scrollbar.minimum()
1742 max_ = scrollbar.maximum()
1743 range_ = max_ - min_
1744 distance = value - min_
1745 nonzero_range = range_ > 0.1
1746 if nonzero_range:
1747 scrolloffset = distance / range_
1748 else:
1749 adjust_scrollbars = False
1751 self.setTransformationAnchor(QtWidgets.QGraphicsView.NoAnchor)
1752 self.scale(scale, scale)
1754 scrollbar = self.verticalScrollBar()
1755 if scrollbar and adjust_scrollbars:
1756 min_ = scrollbar.minimum()
1757 max_ = scrollbar.maximum()
1758 range_ = max_ - min_
1759 value = min_ + int(float(range_) * scrolloffset)
1760 scrollbar.setValue(value)
1762 def add_commits(self, commits):
1763 """Traverse commits and add them to the view."""
1764 self.commits.extend(commits)
1765 scene = self.scene()
1766 for commit in commits:
1767 item = Commit(commit, self.notifier)
1768 self.items[commit.oid] = item
1769 for ref in commit.tags:
1770 self.items[ref] = item
1771 scene.addItem(item)
1773 self.layout_commits()
1774 self.link(commits)
1776 def link(self, commits):
1777 """Create edges linking commits with their parents"""
1778 scene = self.scene()
1779 for commit in commits:
1780 try:
1781 commit_item = self.items[commit.oid]
1782 except KeyError:
1783 # TODO - Handle truncated history viewing
1784 continue
1785 for parent in reversed(commit.parents):
1786 try:
1787 parent_item = self.items[parent.oid]
1788 except KeyError:
1789 # TODO - Handle truncated history viewing
1790 continue
1791 try:
1792 edge = parent_item.edges[commit.oid]
1793 except KeyError:
1794 edge = Edge(parent_item, commit_item)
1795 else:
1796 continue
1797 parent_item.edges[commit.oid] = edge
1798 commit_item.edges[parent.oid] = edge
1799 scene.addItem(edge)
1801 def layout_commits(self):
1802 positions = self.position_nodes()
1804 # Each edge is accounted in two commits. Hence, accumulate invalid
1805 # edges to prevent double edge invalidation.
1806 invalid_edges = set()
1808 for oid, (x, y) in positions.items():
1809 item = self.items[oid]
1811 pos = item.pos()
1812 if pos != (x, y):
1813 item.setPos(x, y)
1815 for edge in item.edges.values():
1816 invalid_edges.add(edge)
1818 for edge in invalid_edges:
1819 edge.commits_were_invalidated()
1821 # Commit node layout technique
1823 # Nodes are aligned by a mesh. Columns and rows are distributed using
1824 # algorithms described below.
1826 # Row assignment algorithm
1828 # The algorithm aims consequent.
1829 # 1. A commit should be above all its parents.
1830 # 2. No commit should be at right side of a commit with a tag in same row.
1831 # This prevents overlapping of tag labels with commits and other labels.
1832 # 3. Commit density should be maximized.
1834 # The algorithm requires that all parents of a commit were assigned column.
1835 # Nodes must be traversed in generation ascend order. This guarantees that all
1836 # parents of a commit were assigned row. So, the algorithm may operate in
1837 # course of column assignment algorithm.
1839 # Row assignment uses frontier. A frontier is a dictionary that contains
1840 # minimum available row index for each column. It propagates during the
1841 # algorithm. Set of cells with tags is also maintained to meet second aim.
1843 # Initialization is performed by reset_rows method. Each new column should
1844 # be declared using declare_column method. Getting row for a cell is
1845 # implemented in alloc_cell method. Frontier must be propagated for any child
1846 # of fork commit which occupies different column. This meets first aim.
1848 # Column assignment algorithm
1850 # The algorithm traverses nodes in generation ascend order. This guarantees
1851 # that a node will be visited after all its parents.
1853 # The set of occupied columns are maintained during work. Initially it is
1854 # empty and no node occupied a column. Empty columns are allocated on demand.
1855 # Free index for column being allocated is searched in following way.
1856 # 1. Start from desired column and look towards graph center (0 column).
1857 # 2. Start from center and look in both directions simultaneously.
1858 # Desired column is defaulted to 0. Fork node should set desired column for
1859 # children equal to its one. This prevents branch from jumping too far from
1860 # its fork.
1862 # Initialization is performed by reset_columns method. Column allocation is
1863 # implemented in alloc_column method. Initialization and main loop are in
1864 # recompute_grid method. The method also embeds row assignment algorithm by
1865 # implementation.
1867 # Actions for each node are follow.
1868 # 1. If the node was not assigned a column then it is assigned empty one.
1869 # 2. Allocate row.
1870 # 3. Allocate columns for children.
1871 # If a child have a column assigned then it should no be overridden. One of
1872 # children is assigned same column as the node. If the node is a fork then the
1873 # child is chosen in generation descent order. This is a heuristic and it only
1874 # affects resulting appearance of the graph. Other children are assigned empty
1875 # columns in same order. It is the heuristic too.
1876 # 4. If no child occupies column of the node then leave it.
1877 # It is possible in consequent situations.
1878 # 4.1 The node is a leaf.
1879 # 4.2 The node is a fork and all its children are already assigned side
1880 # column. It is possible if all the children are merges.
1881 # 4.3 Single node child is a merge that is already assigned a column.
1882 # 5. Propagate frontier with respect to this node.
1883 # Each frontier entry corresponding to column occupied by any node's child
1884 # must be gather than node row index. This meets first aim of the row
1885 # assignment algorithm.
1886 # Note that frontier of child that occupies same row was propagated during
1887 # step 2. Hence, it must be propagated for children on side columns.
1889 def reset_columns(self):
1890 # Some children of displayed commits might not be accounted in
1891 # 'commits' list. It is common case during loading of big graph.
1892 # But, they are assigned a column that must be reseted. Hence, use
1893 # depth-first traversal to reset all columns assigned.
1894 for node in self.commits:
1895 if node.column is None:
1896 continue
1897 stack = [node]
1898 while stack:
1899 node = stack.pop()
1900 node.column = None
1901 for child in node.children:
1902 if child.column is not None:
1903 stack.append(child)
1905 self.columns = {}
1906 self.max_column = 0
1907 self.min_column = 0
1909 def reset_rows(self):
1910 self.frontier = {}
1911 self.tagged_cells = set()
1913 def declare_column(self, column):
1914 if self.frontier:
1915 # Align new column frontier by frontier of nearest column. If all
1916 # columns were left then select maximum frontier value.
1917 if not self.columns:
1918 self.frontier[column] = max(list(self.frontier.values()))
1919 return
1920 # This is heuristic that mostly affects roots. Note that the
1921 # frontier values for fork children will be overridden in course of
1922 # propagate_frontier.
1923 for offset in itertools.count(1):
1924 for c in [column + offset, column - offset]:
1925 if c not in self.columns:
1926 # Column 'c' is not occupied.
1927 continue
1928 try:
1929 frontier = self.frontier[c]
1930 except KeyError:
1931 # Column 'c' was never allocated.
1932 continue
1934 frontier -= 1
1935 # The frontier of the column may be higher because of
1936 # tag overlapping prevention performed for previous head.
1937 try:
1938 if self.frontier[column] >= frontier:
1939 break
1940 except KeyError:
1941 pass
1943 self.frontier[column] = frontier
1944 break
1945 else:
1946 continue
1947 break
1948 else:
1949 # First commit must be assigned 0 row.
1950 self.frontier[column] = 0
1952 def alloc_column(self, column=0):
1953 columns = self.columns
1954 # First, look for free column by moving from desired column to graph
1955 # center (column 0).
1956 for c in range(column, 0, -1 if column > 0 else 1):
1957 if c not in columns:
1958 if c > self.max_column:
1959 self.max_column = c
1960 elif c < self.min_column:
1961 self.min_column = c
1962 break
1963 else:
1964 # If no free column was found between graph center and desired
1965 # column then look for free one by moving from center along both
1966 # directions simultaneously.
1967 for c in itertools.count(0):
1968 if c not in columns:
1969 if c > self.max_column:
1970 self.max_column = c
1971 break
1972 c = -c
1973 if c not in columns:
1974 if c < self.min_column:
1975 self.min_column = c
1976 break
1977 self.declare_column(c)
1978 columns[c] = 1
1979 return c
1981 def alloc_cell(self, column, tags):
1982 # Get empty cell from frontier.
1983 cell_row = self.frontier[column]
1985 if tags:
1986 # Prevent overlapping of tag with cells already allocated a row.
1987 if self.x_off > 0:
1988 can_overlap = list(range(column + 1, self.max_column + 1))
1989 else:
1990 can_overlap = list(range(column - 1, self.min_column - 1, -1))
1991 for c in can_overlap:
1992 frontier = self.frontier[c]
1993 if frontier > cell_row:
1994 cell_row = frontier
1996 # Avoid overlapping with tags of commits at cell_row.
1997 if self.x_off > 0:
1998 can_overlap = list(range(self.min_column, column))
1999 else:
2000 can_overlap = list(range(self.max_column, column, -1))
2001 for cell_row in itertools.count(cell_row):
2002 for c in can_overlap:
2003 if (c, cell_row) in self.tagged_cells:
2004 # Overlapping. Try next row.
2005 break
2006 else:
2007 # No overlapping was found.
2008 break
2009 # Note that all checks should be made for new cell_row value.
2011 if tags:
2012 self.tagged_cells.add((column, cell_row))
2014 # Propagate frontier.
2015 self.frontier[column] = cell_row + 1
2016 return cell_row
2018 def propagate_frontier(self, column, value):
2019 current = self.frontier[column]
2020 if current < value:
2021 self.frontier[column] = value
2023 def leave_column(self, column):
2024 count = self.columns[column]
2025 if count == 1:
2026 del self.columns[column]
2027 else:
2028 self.columns[column] = count - 1
2030 def recompute_grid(self):
2031 self.reset_columns()
2032 self.reset_rows()
2034 for node in sort_by_generation(list(self.commits)):
2035 if node.column is None:
2036 # Node is either root or its parent is not in items. The last
2037 # happens when tree loading is in progress. Allocate new
2038 # columns for such nodes.
2039 node.column = self.alloc_column()
2041 node.row = self.alloc_cell(node.column, node.tags)
2043 # Allocate columns for children which are still without one. Also
2044 # propagate frontier for children.
2045 if node.is_fork():
2046 sorted_children = sorted(
2047 node.children, key=lambda c: c.generation, reverse=True
2049 citer = iter(sorted_children)
2050 for child in citer:
2051 if child.column is None:
2052 # Top most child occupies column of parent.
2053 child.column = node.column
2054 # Note that frontier is propagated in course of
2055 # alloc_cell.
2056 break
2057 self.propagate_frontier(child.column, node.row + 1)
2058 else:
2059 # No child occupies same column.
2060 self.leave_column(node.column)
2061 # Note that the loop below will pass no iteration.
2063 # Rest children are allocated new column.
2064 for child in citer:
2065 if child.column is None:
2066 child.column = self.alloc_column(node.column)
2067 self.propagate_frontier(child.column, node.row + 1)
2068 elif node.children:
2069 child = node.children[0]
2070 if child.column is None:
2071 child.column = node.column
2072 # Note that frontier is propagated in course of alloc_cell.
2073 elif child.column != node.column:
2074 # Child node have other parents and occupies column of one
2075 # of them.
2076 self.leave_column(node.column)
2077 # But frontier must be propagated with respect to this
2078 # parent.
2079 self.propagate_frontier(child.column, node.row + 1)
2080 else:
2081 # This is a leaf node.
2082 self.leave_column(node.column)
2084 def position_nodes(self):
2085 self.recompute_grid()
2087 x_start = self.x_start
2088 x_min = self.x_min
2089 x_off = self.x_off
2090 y_off = self.y_off
2092 positions = {}
2094 for node in self.commits:
2095 x_pos = x_start + node.column * x_off
2096 y_pos = y_off + node.row * y_off
2098 positions[node.oid] = (x_pos, y_pos)
2099 x_min = min(x_min, x_pos)
2101 self.x_min = x_min
2103 return positions
2105 # Qt overrides
2106 def contextMenuEvent(self, event):
2107 self.context_menu_event(event)
2109 def mousePressEvent(self, event):
2110 if event.button() == Qt.MidButton:
2111 pos = event.pos()
2112 self.mouse_start = [pos.x(), pos.y()]
2113 self.saved_matrix = self.transform()
2114 self.is_panning = True
2115 return
2116 if event.button() == Qt.RightButton:
2117 event.ignore()
2118 return
2119 if event.button() == Qt.LeftButton:
2120 self.pressed = True
2121 self.handle_event(QtWidgets.QGraphicsView.mousePressEvent, event)
2123 def mouseMoveEvent(self, event):
2124 pos = self.mapToScene(event.pos())
2125 if self.is_panning:
2126 self.pan(event)
2127 return
2128 self.last_mouse[0] = pos.x()
2129 self.last_mouse[1] = pos.y()
2130 self.handle_event(QtWidgets.QGraphicsView.mouseMoveEvent, event)
2131 if self.pressed:
2132 self.viewport().repaint()
2134 def mouseReleaseEvent(self, event):
2135 self.pressed = False
2136 if event.button() == Qt.MidButton:
2137 self.is_panning = False
2138 return
2139 self.handle_event(QtWidgets.QGraphicsView.mouseReleaseEvent, event)
2140 self.selection_list = []
2141 self.viewport().repaint()
2143 def wheelEvent(self, event):
2144 """Handle Qt mouse wheel events."""
2145 if event.modifiers() & Qt.ControlModifier:
2146 self.wheel_zoom(event)
2147 else:
2148 self.wheel_pan(event)
2150 def fitInView(self, rect, flags=Qt.IgnoreAspectRatio):
2151 """Override fitInView to remove unwanted margins
2153 https://bugreports.qt.io/browse/QTBUG-42331 - based on QT sources
2156 if self.scene() is None or rect.isNull():
2157 return
2158 unity = self.transform().mapRect(QtCore.QRectF(0, 0, 1, 1))
2159 self.scale(1.0 / unity.width(), 1.0 / unity.height())
2160 view_rect = self.viewport().rect()
2161 scene_rect = self.transform().mapRect(rect)
2162 xratio = view_rect.width() / scene_rect.width()
2163 yratio = view_rect.height() / scene_rect.height()
2164 if flags == Qt.KeepAspectRatio:
2165 xratio = yratio = min(xratio, yratio)
2166 elif flags == Qt.KeepAspectRatioByExpanding:
2167 xratio = yratio = max(xratio, yratio)
2168 self.scale(xratio, yratio)
2169 self.centerOn(rect.center())
2172 def sort_by_generation(commits):
2173 if len(commits) < 2:
2174 return commits
2175 commits.sort(key=lambda x: x.generation)
2176 return commits
2179 # Glossary
2180 # ========
2181 # oid -- Git objects IDs (i.e. SHA-1 IDs)
2182 # ref -- Git references that resolve to a commit-ish (HEAD, branches, tags)