dag: use row instead of commit generation during node laying out
[git-cola.git] / cola / widgets / dag.py
blob997dbfd1ac6ba5288c7ad6e6cbfff18f0787900f
1 from __future__ import division, absolute_import, unicode_literals
2 import collections
3 import math
4 from itertools import count
6 from qtpy.QtCore import Qt
7 from qtpy.QtCore import Signal
8 from qtpy import QtCore
9 from qtpy import QtGui
10 from qtpy import QtWidgets
12 from ..compat import maxsize
13 from ..i18n import N_
14 from ..models import dag
15 from .. import core
16 from .. import cmds
17 from .. import difftool
18 from .. import hotkeys
19 from .. import icons
20 from .. import observable
21 from .. import qtcompat
22 from .. import qtutils
23 from . import archive
24 from . import browse
25 from . import completion
26 from . import createbranch
27 from . import createtag
28 from . import defs
29 from . import diff
30 from . import filelist
31 from . import standard
34 def git_dag(model, args=None, settings=None, existing_view=None):
35 """Return a pre-populated git DAG widget."""
36 branch = model.currentbranch
37 # disambiguate between branch names and filenames by using '--'
38 branch_doubledash = branch and (branch + ' --') or ''
39 ctx = dag.DAG(branch_doubledash, 1000)
40 ctx.set_arguments(args)
42 if existing_view is None:
43 view = GitDAG(model, ctx, settings=settings)
44 else:
45 view = existing_view
46 view.set_context(ctx)
47 if ctx.ref:
48 view.display()
49 return view
52 class FocusRedirectProxy(object):
53 """Redirect actions from the main widget to child widgets"""
55 def __init__(self, *widgets):
56 """Provide proxied widgets; the default widget must be first"""
57 self.widgets = widgets
58 self.default = widgets[0]
60 def __getattr__(self, name):
61 return (lambda *args, **kwargs:
62 self._forward_action(name, *args, **kwargs))
64 def _forward_action(self, name, *args, **kwargs):
65 """Forward the captured action to the focused or default widget"""
66 widget = QtWidgets.QApplication.focusWidget()
67 if widget in self.widgets and hasattr(widget, name):
68 fn = getattr(widget, name)
69 else:
70 fn = getattr(self.default, name)
72 return fn(*args, **kwargs)
75 class ViewerMixin(object):
76 """Implementations must provide selected_items()"""
78 def __init__(self):
79 self.selected = None
80 self.clicked = None
81 self.menu_actions = None # provided by implementation
83 def selected_item(self):
84 """Return the currently selected item"""
85 selected_items = self.selected_items()
86 if not selected_items:
87 return None
88 return selected_items[0]
90 def selected_oid(self):
91 item = self.selected_item()
92 if item is None:
93 result = None
94 else:
95 result = item.commit.oid
96 return result
98 def selected_oids(self):
99 return [i.commit for i in self.selected_items()]
101 def with_oid(self, fn):
102 oid = self.selected_oid()
103 if oid:
104 result = fn(oid)
105 else:
106 result = None
107 return result
109 def diff_selected_this(self):
110 clicked_oid = self.clicked.oid
111 selected_oid = self.selected.oid
112 self.diff_commits.emit(selected_oid, clicked_oid)
114 def diff_this_selected(self):
115 clicked_oid = self.clicked.oid
116 selected_oid = self.selected.oid
117 self.diff_commits.emit(clicked_oid, selected_oid)
119 def cherry_pick(self):
120 self.with_oid(lambda oid: cmds.do(cmds.CherryPick, [oid]))
122 def copy_to_clipboard(self):
123 self.with_oid(lambda oid: qtutils.set_clipboard(oid))
125 def create_branch(self):
126 self.with_oid(lambda oid: createbranch.create_new_branch(revision=oid))
128 def create_tag(self):
129 self.with_oid(lambda oid: createtag.create_tag(ref=oid))
131 def create_tarball(self):
132 self.with_oid(lambda oid: archive.show_save_dialog(oid, parent=self))
134 def show_diff(self):
135 self.with_oid(lambda oid:
136 difftool.diff_expression(self, oid + '^!',
137 hide_expr=False, focus_tree=True))
139 def show_dir_diff(self):
140 self.with_oid(lambda oid:
141 cmds.difftool_launch(left=oid, left_take_magic=True,
142 dir_diff=True))
144 def reset_branch_head(self):
145 self.with_oid(lambda oid: cmds.do(cmds.ResetBranchHead, ref=oid))
147 def reset_worktree(self):
148 self.with_oid(lambda oid: cmds.do(cmds.ResetWorktree, ref=oid))
150 def save_blob_dialog(self):
151 self.with_oid(lambda oid: browse.BrowseDialog.browse(oid))
153 def update_menu_actions(self, event):
154 selected_items = self.selected_items()
155 item = self.itemAt(event.pos())
156 if item is None:
157 self.clicked = commit = None
158 else:
159 self.clicked = commit = item.commit
161 has_single_selection = len(selected_items) == 1
162 has_selection = bool(selected_items)
163 can_diff = bool(commit and has_single_selection and
164 commit is not selected_items[0].commit)
166 if can_diff:
167 self.selected = selected_items[0].commit
168 else:
169 self.selected = None
171 self.menu_actions['diff_this_selected'].setEnabled(can_diff)
172 self.menu_actions['diff_selected_this'].setEnabled(can_diff)
173 self.menu_actions['diff_commit'].setEnabled(has_single_selection)
174 self.menu_actions['diff_commit_all'].setEnabled(has_single_selection)
176 self.menu_actions['cherry_pick'].setEnabled(has_single_selection)
177 self.menu_actions['copy'].setEnabled(has_single_selection)
178 self.menu_actions['create_branch'].setEnabled(has_single_selection)
179 self.menu_actions['create_patch'].setEnabled(has_selection)
180 self.menu_actions['create_tag'].setEnabled(has_single_selection)
181 self.menu_actions['create_tarball'].setEnabled(has_single_selection)
182 self.menu_actions['reset_branch_head'].setEnabled(has_single_selection)
183 self.menu_actions['reset_worktree'].setEnabled(has_single_selection)
184 self.menu_actions['save_blob'].setEnabled(has_single_selection)
186 def context_menu_event(self, event):
187 self.update_menu_actions(event)
188 menu = qtutils.create_menu(N_('Actions'), self)
189 menu.addAction(self.menu_actions['diff_this_selected'])
190 menu.addAction(self.menu_actions['diff_selected_this'])
191 menu.addAction(self.menu_actions['diff_commit'])
192 menu.addAction(self.menu_actions['diff_commit_all'])
193 menu.addSeparator()
194 menu.addAction(self.menu_actions['create_branch'])
195 menu.addAction(self.menu_actions['create_tag'])
196 menu.addSeparator()
197 menu.addAction(self.menu_actions['cherry_pick'])
198 menu.addAction(self.menu_actions['create_patch'])
199 menu.addAction(self.menu_actions['create_tarball'])
200 menu.addSeparator()
201 reset_menu = menu.addMenu(N_('Reset'))
202 reset_menu.addAction(self.menu_actions['reset_branch_head'])
203 reset_menu.addAction(self.menu_actions['reset_worktree'])
204 menu.addSeparator()
205 menu.addAction(self.menu_actions['save_blob'])
206 menu.addAction(self.menu_actions['copy'])
207 menu.exec_(self.mapToGlobal(event.pos()))
210 def viewer_actions(widget):
211 return {
212 'diff_this_selected':
213 qtutils.add_action(widget, N_('Diff this -> selected'),
214 widget.proxy.diff_this_selected),
215 'diff_selected_this':
216 qtutils.add_action(widget, N_('Diff selected -> this'),
217 widget.proxy.diff_selected_this),
218 'create_branch':
219 qtutils.add_action(widget, N_('Create Branch'),
220 widget.proxy.create_branch),
221 'create_patch':
222 qtutils.add_action(widget, N_('Create Patch'),
223 widget.proxy.create_patch),
224 'create_tag':
225 qtutils.add_action(widget, N_('Create Tag'),
226 widget.proxy.create_tag),
227 'create_tarball':
228 qtutils.add_action(widget, N_('Save As Tarball/Zip...'),
229 widget.proxy.create_tarball),
230 'cherry_pick':
231 qtutils.add_action(widget, N_('Cherry Pick'),
232 widget.proxy.cherry_pick),
233 'diff_commit':
234 qtutils.add_action(widget, N_('Launch Diff Tool'),
235 widget.proxy.show_diff, hotkeys.DIFF),
236 'diff_commit_all':
237 qtutils.add_action(widget, N_('Launch Directory Diff Tool'),
238 widget.proxy.show_dir_diff, hotkeys.DIFF_SECONDARY),
239 'reset_branch_head':
240 qtutils.add_action(widget, N_('Reset Branch Head'),
241 widget.proxy.reset_branch_head),
242 'reset_worktree':
243 qtutils.add_action(widget, N_('Reset Worktree'),
244 widget.proxy.reset_worktree),
245 'save_blob':
246 qtutils.add_action(widget, N_('Grab File...'),
247 widget.proxy.save_blob_dialog),
248 'copy':
249 qtutils.add_action(widget, N_('Copy SHA-1'),
250 widget.proxy.copy_to_clipboard,
251 QtGui.QKeySequence.Copy),
255 class CommitTreeWidgetItem(QtWidgets.QTreeWidgetItem):
257 def __init__(self, commit, parent=None):
258 QtWidgets.QTreeWidgetItem.__init__(self, parent)
259 self.commit = commit
260 self.setText(0, commit.summary)
261 self.setText(1, commit.author)
262 self.setText(2, commit.authdate)
265 class CommitTreeWidget(standard.TreeWidget, ViewerMixin):
267 diff_commits = Signal(object, object)
269 def __init__(self, notifier, parent):
270 standard.TreeWidget.__init__(self, parent=parent)
271 ViewerMixin.__init__(self)
273 self.setSelectionMode(self.ExtendedSelection)
274 self.setHeaderLabels([N_('Summary'), N_('Author'), N_('Date, Time')])
276 self.oidmap = {}
277 self.menu_actions = None
278 self.notifier = notifier
279 self.selecting = False
280 self.commits = []
282 self.action_up = qtutils.add_action(self, N_('Go Up'),
283 self.go_up, hotkeys.MOVE_UP)
285 self.action_down = qtutils.add_action(self, N_('Go Down'),
286 self.go_down, hotkeys.MOVE_DOWN)
288 notifier.add_observer(diff.COMMITS_SELECTED, self.commits_selected)
290 self.itemSelectionChanged.connect(self.selection_changed)
292 # ViewerMixin
293 def go_up(self):
294 self.goto(self.itemAbove)
296 def go_down(self):
297 self.goto(self.itemBelow)
299 def goto(self, finder):
300 items = self.selected_items()
301 item = items and items[0] or None
302 if item is None:
303 return
304 found = finder(item)
305 if found:
306 self.select([found.commit.oid])
308 def selected_commit_range(self):
309 selected_items = self.selected_items()
310 if not selected_items:
311 return None, None
312 return selected_items[-1].commit.oid, selected_items[0].commit.oid
314 def set_selecting(self, selecting):
315 self.selecting = selecting
317 def selection_changed(self):
318 items = self.selected_items()
319 if not items:
320 return
321 self.set_selecting(True)
322 self.notifier.notify_observers(diff.COMMITS_SELECTED,
323 [i.commit for i in items])
324 self.set_selecting(False)
326 def commits_selected(self, commits):
327 if self.selecting:
328 return
329 with qtutils.BlockSignals(self):
330 self.select([commit.oid for commit in commits])
332 def select(self, oids):
333 if not oids:
334 return
335 self.clearSelection()
336 for idx, oid in enumerate(oids):
337 try:
338 item = self.oidmap[oid]
339 except KeyError:
340 continue
341 self.scrollToItem(item)
342 item.setSelected(True)
344 def adjust_columns(self):
345 width = self.width()-20
346 zero = width * 2 / 3
347 onetwo = width / 6
348 self.setColumnWidth(0, zero)
349 self.setColumnWidth(1, onetwo)
350 self.setColumnWidth(2, onetwo)
352 def clear(self):
353 QtWidgets.QTreeWidget.clear(self)
354 self.oidmap.clear()
355 self.commits = []
357 def add_commits(self, commits):
358 self.commits.extend(commits)
359 items = []
360 for c in reversed(commits):
361 item = CommitTreeWidgetItem(c)
362 items.append(item)
363 self.oidmap[c.oid] = item
364 for tag in c.tags:
365 self.oidmap[tag] = item
366 self.insertTopLevelItems(0, items)
368 def create_patch(self):
369 items = self.selectedItems()
370 if not items:
371 return
372 oids = [item.commit.oid for item in reversed(items)]
373 all_oids = [c.oid for c in self.commits]
374 cmds.do(cmds.FormatPatch, oids, all_oids)
376 # Qt overrides
377 def contextMenuEvent(self, event):
378 self.context_menu_event(event)
380 def mousePressEvent(self, event):
381 if event.button() == Qt.RightButton:
382 event.accept()
383 return
384 QtWidgets.QTreeWidget.mousePressEvent(self, event)
387 class GitDAG(standard.MainWindow):
388 """The git-dag widget."""
389 updated = Signal()
391 def __init__(self, model, ctx, parent=None, settings=None):
392 super(GitDAG, self).__init__(parent)
394 self.setMinimumSize(420, 420)
396 # change when widgets are added/removed
397 self.widget_version = 2
398 self.model = model
399 self.ctx = ctx
400 self.settings = settings
402 self.commits = {}
403 self.commit_list = []
404 self.selection = []
406 self.thread = None
407 self.revtext = completion.GitLogLineEdit()
408 self.maxresults = standard.SpinBox()
410 self.zoom_out = qtutils.create_action_button(
411 tooltip=N_('Zoom Out'), icon=icons.zoom_out())
413 self.zoom_in = qtutils.create_action_button(
414 tooltip=N_('Zoom In'), icon=icons.zoom_in())
416 self.zoom_to_fit = qtutils.create_action_button(
417 tooltip=N_('Zoom to Fit'), icon=icons.zoom_fit_best())
419 self.notifier = notifier = observable.Observable()
420 self.notifier.refs_updated = refs_updated = 'refs_updated'
421 self.notifier.add_observer(refs_updated, self.display)
422 self.notifier.add_observer(filelist.HISTORIES_SELECTED,
423 self.histories_selected)
424 self.notifier.add_observer(filelist.DIFFTOOL_SELECTED,
425 self.difftool_selected)
426 self.notifier.add_observer(diff.COMMITS_SELECTED, self.commits_selected)
428 self.treewidget = CommitTreeWidget(notifier, self)
429 self.diffwidget = diff.DiffWidget(notifier, self, is_commit=True)
430 self.filewidget = filelist.FileWidget(notifier, self)
431 self.graphview = GraphView(notifier, self)
433 self.proxy = FocusRedirectProxy(self.treewidget,
434 self.graphview,
435 self.filewidget)
437 self.viewer_actions = actions = viewer_actions(self)
438 self.treewidget.menu_actions = actions
439 self.graphview.menu_actions = actions
441 self.controls_layout = qtutils.hbox(defs.no_margin, defs.spacing,
442 self.revtext, self.maxresults)
444 self.controls_widget = QtWidgets.QWidget()
445 self.controls_widget.setLayout(self.controls_layout)
447 self.log_dock = qtutils.create_dock(N_('Log'), self, stretch=False)
448 self.log_dock.setWidget(self.treewidget)
449 log_dock_titlebar = self.log_dock.titleBarWidget()
450 log_dock_titlebar.add_corner_widget(self.controls_widget)
452 self.file_dock = qtutils.create_dock(N_('Files'), self)
453 self.file_dock.setWidget(self.filewidget)
455 self.diff_dock = qtutils.create_dock(N_('Diff'), self)
456 self.diff_dock.setWidget(self.diffwidget)
458 self.graph_controls_layout = qtutils.hbox(
459 defs.no_margin, defs.button_spacing,
460 self.zoom_out, self.zoom_in, self.zoom_to_fit,
461 defs.spacing)
463 self.graph_controls_widget = QtWidgets.QWidget()
464 self.graph_controls_widget.setLayout(self.graph_controls_layout)
466 self.graphview_dock = qtutils.create_dock(N_('Graph'), self)
467 self.graphview_dock.setWidget(self.graphview)
468 graph_titlebar = self.graphview_dock.titleBarWidget()
469 graph_titlebar.add_corner_widget(self.graph_controls_widget)
471 self.lock_layout_action = qtutils.add_action_bool(
472 self, N_('Lock Layout'), self.set_lock_layout, False)
474 self.refresh_action = qtutils.add_action(
475 self, N_('Refresh'), self.refresh, hotkeys.REFRESH)
477 # Create the application menu
478 self.menubar = QtWidgets.QMenuBar(self)
480 # View Menu
481 self.view_menu = qtutils.create_menu(N_('View'), self.menubar)
482 self.view_menu.addAction(self.refresh_action)
484 self.view_menu.addAction(self.log_dock.toggleViewAction())
485 self.view_menu.addAction(self.graphview_dock.toggleViewAction())
486 self.view_menu.addAction(self.diff_dock.toggleViewAction())
487 self.view_menu.addAction(self.file_dock.toggleViewAction())
488 self.view_menu.addSeparator()
489 self.view_menu.addAction(self.lock_layout_action)
491 self.menubar.addAction(self.view_menu.menuAction())
492 self.setMenuBar(self.menubar)
494 left = Qt.LeftDockWidgetArea
495 right = Qt.RightDockWidgetArea
496 self.addDockWidget(left, self.log_dock)
497 self.addDockWidget(left, self.diff_dock)
498 self.addDockWidget(right, self.graphview_dock)
499 self.addDockWidget(right, self.file_dock)
501 # Also re-loads dag.* from the saved state
502 self.init_state(settings, self.resize_to_desktop)
504 qtutils.connect_button(self.zoom_out, self.graphview.zoom_out)
505 qtutils.connect_button(self.zoom_in, self.graphview.zoom_in)
506 qtutils.connect_button(self.zoom_to_fit,
507 self.graphview.zoom_to_fit)
509 self.treewidget.diff_commits.connect(self.diff_commits)
510 self.graphview.diff_commits.connect(self.diff_commits)
512 self.maxresults.editingFinished.connect(self.display)
513 self.revtext.textChanged.connect(self.text_changed)
515 self.revtext.activated.connect(self.display)
516 self.revtext.enter.connect(self.display)
517 self.revtext.down.connect(self.focus_tree)
519 # The model is updated in another thread so use
520 # signals/slots to bring control back to the main GUI thread
521 self.model.add_observer(self.model.message_updated, self.updated.emit)
522 self.updated.connect(self.model_updated, type=Qt.QueuedConnection)
524 qtutils.add_action(self, 'Focus Input', self.focus_input, hotkeys.FOCUS)
525 qtutils.add_close_action(self)
527 self.set_context(ctx)
529 def set_context(self, ctx):
530 self.ctx = ctx
532 # Update fields affected by model
533 self.revtext.setText(ctx.ref)
534 self.maxresults.setValue(ctx.count)
535 self.update_window_title()
537 if self.thread is not None:
538 self.thread.stop()
539 self.thread = ReaderThread(ctx, self)
541 thread = self.thread
542 thread.begin.connect(self.thread_begin, type=Qt.QueuedConnection)
543 thread.status.connect(self.thread_status, type=Qt.QueuedConnection)
544 thread.add.connect(self.add_commits, type=Qt.QueuedConnection)
545 thread.end.connect(self.thread_end, type=Qt.QueuedConnection)
547 def focus_input(self):
548 self.revtext.setFocus()
550 def focus_tree(self):
551 self.treewidget.setFocus()
553 def text_changed(self, txt):
554 self.ctx.ref = txt
555 self.update_window_title()
557 def update_window_title(self):
558 project = self.model.project
559 if self.ctx.ref:
560 self.setWindowTitle(N_('%(project)s: %(ref)s - DAG')
561 % dict(project=project, ref=self.ctx.ref))
562 else:
563 self.setWindowTitle(project + N_(' - DAG'))
565 def export_state(self):
566 state = standard.MainWindow.export_state(self)
567 state['count'] = self.ctx.count
568 return state
570 def apply_state(self, state):
571 result = standard.MainWindow.apply_state(self, state)
572 try:
573 count = state['count']
574 if self.ctx.overridden('count'):
575 count = self.ctx.count
576 except:
577 count = self.ctx.count
578 result = False
579 self.ctx.set_count(count)
580 self.lock_layout_action.setChecked(state.get('lock_layout', False))
581 return result
583 def model_updated(self):
584 self.display()
586 def refresh(self):
587 cmds.do(cmds.Refresh)
589 def display(self):
590 new_ref = self.revtext.value()
591 new_count = self.maxresults.value()
593 self.thread.stop()
594 self.ctx.set_ref(new_ref)
595 self.ctx.set_count(new_count)
596 self.thread.start()
598 def show(self):
599 standard.MainWindow.show(self)
600 self.treewidget.adjust_columns()
602 def commits_selected(self, commits):
603 if commits:
604 self.selection = commits
606 def clear(self):
607 self.commits.clear()
608 self.commit_list = []
609 self.graphview.clear()
610 self.treewidget.clear()
612 def add_commits(self, commits):
613 self.commit_list.extend(commits)
614 # Keep track of commits
615 for commit_obj in commits:
616 self.commits[commit_obj.oid] = commit_obj
617 for tag in commit_obj.tags:
618 self.commits[tag] = commit_obj
619 self.graphview.add_commits(commits)
620 self.treewidget.add_commits(commits)
622 def thread_begin(self):
623 self.clear()
625 def thread_end(self):
626 self.focus_tree()
627 self.restore_selection()
629 def thread_status(self, successful):
630 self.revtext.hint.set_error(not successful)
632 def restore_selection(self):
633 selection = self.selection
634 try:
635 commit_obj = self.commit_list[-1]
636 except IndexError:
637 # No commits, exist, early-out
638 return
640 new_commits = [self.commits.get(s.oid, None) for s in selection]
641 new_commits = [c for c in new_commits if c is not None]
642 if new_commits:
643 # The old selection exists in the new state
644 self.notifier.notify_observers(diff.COMMITS_SELECTED, new_commits)
645 else:
646 # The old selection is now empty. Select the top-most commit
647 self.notifier.notify_observers(diff.COMMITS_SELECTED, [commit_obj])
649 self.graphview.update_scene_rect()
650 self.graphview.set_initial_view()
652 def diff_commits(self, a, b):
653 paths = self.ctx.paths()
654 if paths:
655 cmds.difftool_launch(left=a, right=b, paths=paths)
656 else:
657 difftool.diff_commits(self, a, b)
659 # Qt overrides
660 def closeEvent(self, event):
661 self.revtext.close_popup()
662 self.thread.stop()
663 standard.MainWindow.closeEvent(self, event)
665 def resizeEvent(self, e):
666 standard.MainWindow.resizeEvent(self, e)
667 self.treewidget.adjust_columns()
669 def histories_selected(self, histories):
670 argv = [self.model.currentbranch, '--']
671 argv.extend(histories)
672 text = core.list2cmdline(argv)
673 self.revtext.setText(text)
674 self.display()
676 def difftool_selected(self, files):
677 bottom, top = self.treewidget.selected_commit_range()
678 if not top:
679 return
680 cmds.difftool_launch(left=bottom, left_take_parent=True,
681 right=top, paths=files)
684 class ReaderThread(QtCore.QThread):
685 begin = Signal()
686 add = Signal(object)
687 end = Signal()
688 status = Signal(object)
690 def __init__(self, ctx, parent):
691 QtCore.QThread.__init__(self, parent)
692 self.ctx = ctx
693 self._abort = False
694 self._stop = False
695 self._mutex = QtCore.QMutex()
696 self._condition = QtCore.QWaitCondition()
698 def run(self):
699 repo = dag.RepoReader(self.ctx)
700 repo.reset()
701 self.begin.emit()
702 commits = []
703 for c in repo:
704 self._mutex.lock()
705 if self._stop:
706 self._condition.wait(self._mutex)
707 self._mutex.unlock()
708 if self._abort:
709 repo.reset()
710 return
711 commits.append(c)
712 if len(commits) >= 512:
713 self.add.emit(commits)
714 commits = []
716 self.status.emit(repo.returncode == 0)
717 if commits:
718 self.add.emit(commits)
719 self.end.emit()
721 def start(self):
722 self._abort = False
723 self._stop = False
724 QtCore.QThread.start(self)
726 def pause(self):
727 self._mutex.lock()
728 self._stop = True
729 self._mutex.unlock()
731 def resume(self):
732 self._mutex.lock()
733 self._stop = False
734 self._mutex.unlock()
735 self._condition.wakeOne()
737 def stop(self):
738 self._abort = True
739 self.wait()
742 class Cache(object):
743 pass
746 class Edge(QtWidgets.QGraphicsItem):
747 item_type = QtWidgets.QGraphicsItem.UserType + 1
749 def __init__(self, source, dest):
751 QtWidgets.QGraphicsItem.__init__(self)
753 self.setAcceptedMouseButtons(Qt.NoButton)
754 self.source = source
755 self.dest = dest
756 self.commit = source.commit
757 self.setZValue(-2)
759 dest_pt = Commit.item_bbox.center()
761 self.source_pt = self.mapFromItem(self.source, dest_pt)
762 self.dest_pt = self.mapFromItem(self.dest, dest_pt)
763 self.line = QtCore.QLineF(self.source_pt, self.dest_pt)
765 width = self.dest_pt.x() - self.source_pt.x()
766 height = self.dest_pt.y() - self.source_pt.y()
767 rect = QtCore.QRectF(self.source_pt, QtCore.QSizeF(width, height))
768 self.bound = rect.normalized()
770 # Choose a new color for new branch edges
771 if self.source.x() < self.dest.x():
772 color = EdgeColor.cycle()
773 line = Qt.SolidLine
774 elif self.source.x() != self.dest.x():
775 color = EdgeColor.current()
776 line = Qt.SolidLine
777 else:
778 color = EdgeColor.current()
779 line = Qt.SolidLine
781 self.pen = QtGui.QPen(color, 4.0, line, Qt.SquareCap, Qt.RoundJoin)
783 # Qt overrides
784 def type(self):
785 return self.item_type
787 def boundingRect(self):
788 return self.bound
790 def paint(self, painter, option, widget):
791 QRectF = QtCore.QRectF
792 QPointF = QtCore.QPointF
794 arc_rect = 10
795 connector_length = 5
797 painter.setPen(self.pen)
798 path = QtGui.QPainterPath()
800 if self.source.x() == self.dest.x():
801 path.moveTo(self.source.x(), self.source.y())
802 path.lineTo(self.dest.x(), self.dest.y())
803 painter.drawPath(path)
804 else:
805 # Define points starting from source
806 point1 = QPointF(self.source.x(), self.source.y())
807 point2 = QPointF(point1.x(), point1.y() - connector_length)
808 point3 = QPointF(point2.x() + arc_rect, point2.y() - arc_rect)
810 # Define points starting from dest
811 point4 = QPointF(self.dest.x(), self.dest.y())
812 point5 = QPointF(point4.x(), point3.y() - arc_rect)
813 point6 = QPointF(point5.x() - arc_rect, point5.y() + arc_rect)
815 start_angle_arc1 = 180
816 span_angle_arc1 = 90
817 start_angle_arc2 = 90
818 span_angle_arc2 = -90
820 # If the dest is at the left of the source, then we
821 # need to reverse some values
822 if self.source.x() > self.dest.x():
823 point5 = QPointF(point4.x(), point4.y() + connector_length)
824 point6 = QPointF(point5.x() + arc_rect, point5.y() + arc_rect)
825 point3 = QPointF(self.source.x() - arc_rect, point6.y())
826 point2 = QPointF(self.source.x(), point3.y() + arc_rect)
828 span_angle_arc1 = 90
830 path.moveTo(point1)
831 path.lineTo(point2)
832 path.arcTo(QRectF(point2, point3),
833 start_angle_arc1, span_angle_arc1)
834 path.lineTo(point6)
835 path.arcTo(QRectF(point6, point5),
836 start_angle_arc2, span_angle_arc2)
837 path.lineTo(point4)
838 painter.drawPath(path)
841 class EdgeColor(object):
842 """An edge color factory"""
844 current_color_index = 0
845 colors = [
846 QtGui.QColor(Qt.red),
847 QtGui.QColor(Qt.green),
848 QtGui.QColor(Qt.blue),
849 QtGui.QColor(Qt.black),
850 QtGui.QColor(Qt.darkRed),
851 QtGui.QColor(Qt.darkGreen),
852 QtGui.QColor(Qt.darkBlue),
853 QtGui.QColor(Qt.cyan),
854 QtGui.QColor(Qt.magenta),
855 # Orange; Qt.yellow is too low-contrast
856 qtutils.rgba(0xff, 0x66, 0x00),
857 QtGui.QColor(Qt.gray),
858 QtGui.QColor(Qt.darkCyan),
859 QtGui.QColor(Qt.darkMagenta),
860 QtGui.QColor(Qt.darkYellow),
861 QtGui.QColor(Qt.darkGray),
864 @classmethod
865 def cycle(cls):
866 cls.current_color_index += 1
867 cls.current_color_index %= len(cls.colors)
868 color = cls.colors[cls.current_color_index]
869 color.setAlpha(128)
870 return color
872 @classmethod
873 def current(cls):
874 return cls.colors[cls.current_color_index]
876 @classmethod
877 def reset(cls):
878 cls.current_color_index = 0
881 class Commit(QtWidgets.QGraphicsItem):
882 item_type = QtWidgets.QGraphicsItem.UserType + 2
883 commit_radius = 12.0
884 merge_radius = 18.0
886 item_shape = QtGui.QPainterPath()
887 item_shape.addRect(commit_radius/-2.0,
888 commit_radius/-2.0,
889 commit_radius, commit_radius)
890 item_bbox = item_shape.boundingRect()
892 inner_rect = QtGui.QPainterPath()
893 inner_rect.addRect(commit_radius/-2.0 + 2.0,
894 commit_radius/-2.0 + 2.0,
895 commit_radius - 4.0,
896 commit_radius - 4.0)
897 inner_rect = inner_rect.boundingRect()
899 commit_color = QtGui.QColor(Qt.white)
900 outline_color = commit_color.darker()
901 merge_color = QtGui.QColor(Qt.lightGray)
903 commit_selected_color = QtGui.QColor(Qt.green)
904 selected_outline_color = commit_selected_color.darker()
906 commit_pen = QtGui.QPen()
907 commit_pen.setWidth(1.0)
908 commit_pen.setColor(outline_color)
910 def __init__(self, commit,
911 notifier,
912 selectable=QtWidgets.QGraphicsItem.ItemIsSelectable,
913 cursor=Qt.PointingHandCursor,
914 xpos=commit_radius/2.0 + 1.0,
915 cached_commit_color=commit_color,
916 cached_merge_color=merge_color):
918 QtWidgets.QGraphicsItem.__init__(self)
920 self.commit = commit
921 self.notifier = notifier
923 self.setZValue(0)
924 self.setFlag(selectable)
925 self.setCursor(cursor)
926 self.setToolTip(commit.oid[:7] + ': ' + commit.summary)
928 if commit.tags:
929 self.label = label = Label(commit)
930 label.setParentItem(self)
931 label.setPos(xpos, -self.commit_radius/2.0)
932 else:
933 self.label = None
935 if len(commit.parents) > 1:
936 self.brush = cached_merge_color
937 else:
938 self.brush = cached_commit_color
940 self.pressed = False
941 self.dragged = False
943 def blockSignals(self, blocked):
944 self.notifier.notification_enabled = not blocked
946 def itemChange(self, change, value):
947 if change == QtWidgets.QGraphicsItem.ItemSelectedHasChanged:
948 # Broadcast selection to other widgets
949 selected_items = self.scene().selectedItems()
950 commits = [item.commit for item in selected_items]
951 self.scene().parent().set_selecting(True)
952 self.notifier.notify_observers(diff.COMMITS_SELECTED, commits)
953 self.scene().parent().set_selecting(False)
955 # Cache the pen for use in paint()
956 if value:
957 self.brush = self.commit_selected_color
958 color = self.selected_outline_color
959 else:
960 if len(self.commit.parents) > 1:
961 self.brush = self.merge_color
962 else:
963 self.brush = self.commit_color
964 color = self.outline_color
965 commit_pen = QtGui.QPen()
966 commit_pen.setWidth(1.0)
967 commit_pen.setColor(color)
968 self.commit_pen = commit_pen
970 return QtWidgets.QGraphicsItem.itemChange(self, change, value)
972 def type(self):
973 return self.item_type
975 def boundingRect(self, rect=item_bbox):
976 return rect
978 def shape(self):
979 return self.item_shape
981 def paint(self, painter, option, widget,
982 inner=inner_rect,
983 cache=Cache):
985 # Do not draw outside the exposed rect
986 painter.setClipRect(option.exposedRect)
988 # Draw ellipse
989 painter.setPen(self.commit_pen)
990 painter.setBrush(self.brush)
991 painter.drawEllipse(inner)
993 def mousePressEvent(self, event):
994 QtWidgets.QGraphicsItem.mousePressEvent(self, event)
995 self.pressed = True
996 self.selected = self.isSelected()
998 def mouseMoveEvent(self, event):
999 if self.pressed:
1000 self.dragged = True
1001 QtWidgets.QGraphicsItem.mouseMoveEvent(self, event)
1003 def mouseReleaseEvent(self, event):
1004 QtWidgets.QGraphicsItem.mouseReleaseEvent(self, event)
1005 if (not self.dragged and
1006 self.selected and
1007 event.button() == Qt.LeftButton):
1008 return
1009 self.pressed = False
1010 self.dragged = False
1013 class Label(QtWidgets.QGraphicsItem):
1014 item_type = QtWidgets.QGraphicsItem.UserType + 3
1016 width = 72
1017 height = 18
1019 item_shape = QtGui.QPainterPath()
1020 item_shape.addRect(0, 0, width, height)
1021 item_bbox = item_shape.boundingRect()
1023 text_options = QtGui.QTextOption()
1024 text_options.setAlignment(Qt.AlignCenter)
1025 text_options.setAlignment(Qt.AlignVCenter)
1027 def __init__(self, commit,
1028 other_color=QtGui.QColor(Qt.white),
1029 head_color=QtGui.QColor(Qt.green)):
1030 QtWidgets.QGraphicsItem.__init__(self)
1031 self.setZValue(-1)
1033 # Starts with enough space for two tags. Any more and the commit
1034 # needs to be taller to accommodate.
1035 self.commit = commit
1037 if 'HEAD' in commit.tags:
1038 self.color = head_color
1039 else:
1040 self.color = other_color
1042 self.color.setAlpha(180)
1043 self.pen = QtGui.QPen()
1044 self.pen.setColor(self.color.darker())
1045 self.pen.setWidth(1.0)
1047 def type(self):
1048 return self.item_type
1050 def boundingRect(self, rect=item_bbox):
1051 return rect
1053 def shape(self):
1054 return self.item_shape
1056 def paint(self, painter, option, widget,
1057 text_opts=text_options,
1058 black=Qt.black,
1059 cache=Cache):
1060 try:
1061 font = cache.label_font
1062 except AttributeError:
1063 font = cache.label_font = QtWidgets.QApplication.font()
1064 font.setPointSize(6)
1066 # Draw tags
1067 painter.setBrush(self.color)
1068 painter.setPen(self.pen)
1069 painter.setFont(font)
1071 current_width = 0
1073 QRectF = QtCore.QRectF
1074 for tag in self.commit.tags:
1075 text_rect = painter.boundingRect(
1076 QRectF(current_width, 0, 0, 0), Qt.TextSingleLine, tag)
1077 box_rect = text_rect.adjusted(-1, -1, 1, 1)
1078 painter.drawRoundedRect(box_rect, 2, 2)
1079 painter.drawText(text_rect, Qt.TextSingleLine, tag)
1080 current_width += text_rect.width() + 5
1083 class GraphView(QtWidgets.QGraphicsView, ViewerMixin):
1085 diff_commits = Signal(object, object)
1087 x_min = 24
1088 x_max = 0
1089 y_min = 24
1091 x_adjust = Commit.commit_radius*4/3
1092 y_adjust = Commit.commit_radius*4/3
1094 x_off = 18
1095 y_off = -24
1097 def __init__(self, notifier, parent):
1098 QtWidgets.QGraphicsView.__init__(self, parent)
1099 ViewerMixin.__init__(self)
1101 highlight = self.palette().color(QtGui.QPalette.Highlight)
1102 Commit.commit_selected_color = highlight
1103 Commit.selected_outline_color = highlight.darker()
1105 self.selection_list = []
1106 self.menu_actions = None
1107 self.notifier = notifier
1108 self.commits = []
1109 self.items = {}
1110 self.saved_matrix = self.transform()
1112 self.x_offsets = collections.defaultdict(lambda: self.x_min)
1114 self.is_panning = False
1115 self.pressed = False
1116 self.selecting = False
1117 self.last_mouse = [0, 0]
1118 self.zoom = 2
1119 self.setDragMode(self.RubberBandDrag)
1121 scene = QtWidgets.QGraphicsScene(self)
1122 scene.setItemIndexMethod(QtWidgets.QGraphicsScene.NoIndex)
1123 self.setScene(scene)
1125 self.setRenderHint(QtGui.QPainter.Antialiasing)
1126 self.setViewportUpdateMode(self.BoundingRectViewportUpdate)
1127 self.setCacheMode(QtWidgets.QGraphicsView.CacheBackground)
1128 self.setTransformationAnchor(QtWidgets.QGraphicsView.AnchorUnderMouse)
1129 self.setResizeAnchor(QtWidgets.QGraphicsView.NoAnchor)
1130 self.setBackgroundBrush(QtGui.QColor(Qt.white))
1132 qtutils.add_action(self, N_('Zoom In'), self.zoom_in,
1133 hotkeys.ZOOM_IN, hotkeys.ZOOM_IN_SECONDARY)
1135 qtutils.add_action(self, N_('Zoom Out'), self.zoom_out,
1136 hotkeys.ZOOM_OUT)
1138 qtutils.add_action(self, N_('Zoom to Fit'),
1139 self.zoom_to_fit, hotkeys.FIT)
1141 qtutils.add_action(self, N_('Select Parent'),
1142 self.select_parent, hotkeys.MOVE_DOWN_TERTIARY)
1144 qtutils.add_action(self, N_('Select Oldest Parent'),
1145 self.select_oldest_parent, hotkeys.MOVE_DOWN)
1147 qtutils.add_action(self, N_('Select Child'),
1148 self.select_child, hotkeys.MOVE_UP_TERTIARY)
1150 qtutils.add_action(self, N_('Select Newest Child'),
1151 self.select_newest_child, hotkeys.MOVE_UP)
1153 notifier.add_observer(diff.COMMITS_SELECTED, self.commits_selected)
1155 def clear(self):
1156 EdgeColor.reset()
1157 self.scene().clear()
1158 self.selection_list = []
1159 self.items.clear()
1160 self.x_offsets.clear()
1161 self.x_max = 24
1162 self.y_min = 24
1163 self.commits = []
1165 # ViewerMixin interface
1166 def selected_items(self):
1167 """Return the currently selected items"""
1168 return self.scene().selectedItems()
1170 def zoom_in(self):
1171 self.scale_view(1.5)
1173 def zoom_out(self):
1174 self.scale_view(1.0/1.5)
1176 def commits_selected(self, commits):
1177 if self.selecting:
1178 return
1179 self.select([commit.oid for commit in commits])
1181 def select(self, oids):
1182 """Select the item for the oids"""
1183 self.scene().clearSelection()
1184 for oid in oids:
1185 try:
1186 item = self.items[oid]
1187 except KeyError:
1188 continue
1189 item.blockSignals(True)
1190 item.setSelected(True)
1191 item.blockSignals(False)
1192 item_rect = item.sceneTransform().mapRect(item.boundingRect())
1193 self.ensureVisible(item_rect)
1195 def get_item_by_generation(self, commits, criteria_fn):
1196 """Return the item for the commit matching criteria"""
1197 if not commits:
1198 return None
1199 generation = None
1200 for commit in commits:
1201 if (generation is None or
1202 criteria_fn(generation, commit.generation)):
1203 oid = commit.oid
1204 generation = commit.generation
1205 try:
1206 return self.items[oid]
1207 except KeyError:
1208 return None
1210 def oldest_item(self, commits):
1211 """Return the item for the commit with the oldest generation number"""
1212 return self.get_item_by_generation(commits, lambda a, b: a > b)
1214 def newest_item(self, commits):
1215 """Return the item for the commit with the newest generation number"""
1216 return self.get_item_by_generation(commits, lambda a, b: a < b)
1218 def create_patch(self):
1219 items = self.selected_items()
1220 if not items:
1221 return
1222 selected_commits = self.sort_by_generation([n.commit for n in items])
1223 oids = [c.oid for c in selected_commits]
1224 all_oids = [c.oid for c in self.commits]
1225 cmds.do(cmds.FormatPatch, oids, all_oids)
1227 def select_parent(self):
1228 """Select the parent with the newest generation number"""
1229 selected_item = self.selected_item()
1230 if selected_item is None:
1231 return
1232 parent_item = self.newest_item(selected_item.commit.parents)
1233 if parent_item is None:
1234 return
1235 selected_item.setSelected(False)
1236 parent_item.setSelected(True)
1237 self.ensureVisible(
1238 parent_item.mapRectToScene(parent_item.boundingRect()))
1240 def select_oldest_parent(self):
1241 """Select the parent with the oldest generation number"""
1242 selected_item = self.selected_item()
1243 if selected_item is None:
1244 return
1245 parent_item = self.oldest_item(selected_item.commit.parents)
1246 if parent_item is None:
1247 return
1248 selected_item.setSelected(False)
1249 parent_item.setSelected(True)
1250 scene_rect = parent_item.mapRectToScene(parent_item.boundingRect())
1251 self.ensureVisible(scene_rect)
1253 def select_child(self):
1254 """Select the child with the oldest generation number"""
1255 selected_item = self.selected_item()
1256 if selected_item is None:
1257 return
1258 child_item = self.oldest_item(selected_item.commit.children)
1259 if child_item is None:
1260 return
1261 selected_item.setSelected(False)
1262 child_item.setSelected(True)
1263 scene_rect = child_item.mapRectToScene(child_item.boundingRect())
1264 self.ensureVisible(scene_rect)
1266 def select_newest_child(self):
1267 """Select the Nth child with the newest generation number (N > 1)"""
1268 selected_item = self.selected_item()
1269 if selected_item is None:
1270 return
1271 if len(selected_item.commit.children) > 1:
1272 children = selected_item.commit.children[1:]
1273 else:
1274 children = selected_item.commit.children
1275 child_item = self.newest_item(children)
1276 if child_item is None:
1277 return
1278 selected_item.setSelected(False)
1279 child_item.setSelected(True)
1280 scene_rect = child_item.mapRectToScene(child_item.boundingRect())
1281 self.ensureVisible(scene_rect)
1283 def set_initial_view(self):
1284 self_commits = self.commits
1285 self_items = self.items
1287 items = self.selected_items()
1288 if not items:
1289 commits = self_commits[-8:]
1290 items = [self_items[c.oid] for c in commits]
1292 self.fit_view_to_items(items)
1294 def zoom_to_fit(self):
1295 """Fit selected items into the viewport"""
1297 items = self.selected_items()
1298 self.fit_view_to_items(items)
1300 def fit_view_to_items(self, items):
1301 if not items:
1302 rect = self.scene().itemsBoundingRect()
1303 else:
1304 x_min = y_min = maxsize
1305 x_max = y_max = -maxsize
1307 for item in items:
1308 pos = item.pos()
1309 item_rect = item.boundingRect()
1310 x_off = item_rect.width() * 5
1311 y_off = item_rect.height() * 10
1312 x_min = min(x_min, pos.x())
1313 y_min = min(y_min, pos.y()-y_off)
1314 x_max = max(x_max, pos.x()+x_off)
1315 y_max = max(y_max, pos.y())
1316 rect = QtCore.QRectF(x_min, y_min, x_max-x_min, y_max-y_min)
1318 x_adjust = GraphView.x_adjust
1319 y_adjust = GraphView.y_adjust
1321 rect.setX(rect.x() - x_adjust)
1322 rect.setY(rect.y() - y_adjust)
1323 rect.setHeight(rect.height() + y_adjust*2)
1324 rect.setWidth(rect.width() + x_adjust*2)
1326 self.fitInView(rect, Qt.KeepAspectRatio)
1327 self.scene().invalidate()
1329 def save_selection(self, event):
1330 if event.button() != Qt.LeftButton:
1331 return
1332 elif Qt.ShiftModifier != event.modifiers():
1333 return
1334 self.selection_list = self.selected_items()
1336 def restore_selection(self, event):
1337 if Qt.ShiftModifier != event.modifiers():
1338 return
1339 for item in self.selection_list:
1340 item.setSelected(True)
1342 def handle_event(self, event_handler, event):
1343 self.save_selection(event)
1344 event_handler(self, event)
1345 self.restore_selection(event)
1346 self.update()
1348 def set_selecting(self, selecting):
1349 self.selecting = selecting
1351 def pan(self, event):
1352 pos = event.pos()
1353 dx = pos.x() - self.mouse_start[0]
1354 dy = pos.y() - self.mouse_start[1]
1356 if dx == 0 and dy == 0:
1357 return
1359 rect = QtCore.QRect(0, 0, abs(dx), abs(dy))
1360 delta = self.mapToScene(rect).boundingRect()
1362 tx = delta.width()
1363 if dx < 0.0:
1364 tx = -tx
1366 ty = delta.height()
1367 if dy < 0.0:
1368 ty = -ty
1370 matrix = self.transform()
1371 matrix.reset()
1372 matrix *= self.saved_matrix
1373 matrix.translate(tx, ty)
1375 self.setTransformationAnchor(QtWidgets.QGraphicsView.NoAnchor)
1376 self.setTransform(matrix)
1378 def wheel_zoom(self, event):
1379 """Handle mouse wheel zooming."""
1380 delta = qtcompat.wheel_delta(event)
1381 zoom = math.pow(2.0, delta/512.0)
1382 factor = (self.transform()
1383 .scale(zoom, zoom)
1384 .mapRect(QtCore.QRectF(0.0, 0.0, 1.0, 1.0))
1385 .width())
1386 if factor < 0.014 or factor > 42.0:
1387 return
1388 self.setTransformationAnchor(QtWidgets.QGraphicsView.AnchorUnderMouse)
1389 self.zoom = zoom
1390 self.scale(zoom, zoom)
1392 def wheel_pan(self, event):
1393 """Handle mouse wheel panning."""
1394 unit = QtCore.QRectF(0.0, 0.0, 1.0, 1.0)
1395 factor = 1.0 / self.transform().mapRect(unit).width()
1396 tx, ty = qtcompat.wheel_translation(event)
1398 matrix = self.transform().translate(tx * factor, ty * factor)
1399 self.setTransformationAnchor(QtWidgets.QGraphicsView.NoAnchor)
1400 self.setTransform(matrix)
1402 def scale_view(self, scale):
1403 factor = (self.transform()
1404 .scale(scale, scale)
1405 .mapRect(QtCore.QRectF(0, 0, 1, 1))
1406 .width())
1407 if factor < 0.07 or factor > 100.0:
1408 return
1409 self.zoom = scale
1411 adjust_scrollbars = True
1412 scrollbar = self.verticalScrollBar()
1413 if scrollbar:
1414 value = scrollbar.value()
1415 min_ = scrollbar.minimum()
1416 max_ = scrollbar.maximum()
1417 range_ = max_ - min_
1418 distance = value - min_
1419 nonzero_range = range_ > 0.1
1420 if nonzero_range:
1421 scrolloffset = distance/range_
1422 else:
1423 adjust_scrollbars = False
1425 self.setTransformationAnchor(QtWidgets.QGraphicsView.NoAnchor)
1426 self.scale(scale, scale)
1428 scrollbar = self.verticalScrollBar()
1429 if scrollbar and adjust_scrollbars:
1430 min_ = scrollbar.minimum()
1431 max_ = scrollbar.maximum()
1432 range_ = max_ - min_
1433 value = min_ + int(float(range_) * scrolloffset)
1434 scrollbar.setValue(value)
1436 def add_commits(self, commits):
1437 """Traverse commits and add them to the view."""
1438 self.commits.extend(commits)
1439 scene = self.scene()
1440 for commit in commits:
1441 item = Commit(commit, self.notifier)
1442 self.items[commit.oid] = item
1443 for ref in commit.tags:
1444 self.items[ref] = item
1445 scene.addItem(item)
1447 self.layout_commits()
1448 self.link(commits)
1450 def link(self, commits):
1451 """Create edges linking commits with their parents"""
1452 scene = self.scene()
1453 for commit in commits:
1454 try:
1455 commit_item = self.items[commit.oid]
1456 except KeyError:
1457 # TODO - Handle truncated history viewing
1458 continue
1459 for parent in reversed(commit.parents):
1460 try:
1461 parent_item = self.items[parent.oid]
1462 except KeyError:
1463 # TODO - Handle truncated history viewing
1464 continue
1465 edge = Edge(parent_item, commit_item)
1466 scene.addItem(edge)
1468 def layout_commits(self):
1469 positions = self.position_nodes()
1470 for oid, (x, y) in positions.items():
1471 item = self.items[oid]
1472 item.setPos(x, y)
1474 """Commit node layout technique
1476 Nodes are aligned by a mesh. Columns and rows are distributed using
1477 algorithms described below.
1479 Row assignment algorithm
1481 The algorithm aims consequent.
1482 1. A commit should be above all its parents.
1483 2. No commit should be at right side of a commit with a tag in same row.
1484 This prevents overlapping of tag labels with commits and other labels.
1485 3. Commit density should be maximized.
1487 The algorithm requires that all parents of a commit were assigned column.
1488 Nodes must be traversed in generation ascend order. This guarantees that all
1489 parents of a commit were assigned row. So, the algorithm may operate in course
1490 of column assignment algorithm.
1492 Row assignment uses frontier. A frontier is a dictionary that contains
1493 minimum available row index for each column. It propagates during the
1494 algorithm. Set of cells with tags is also maintained to meet second aim.
1496 Initialization is performed by reset_rows method. Each new column should
1497 be declared using declare_column method. Getting row for a cell is implemented
1498 in alloc_cell method. Frontier must be propagated for any child of fork
1499 commit which occupies different column. This meets first aim.
1501 Column assignment algorithm
1503 The algorithm traverses nodes in generation ascend order. This guarantees
1504 that a node will be visited after all its parents.
1506 The set of occupied columns are maintained during work. Initially it is
1507 empty and no node occupied a column. Empty columns are selected by request in
1508 index ascend order starting from 0. Each column has its reference counter.
1509 Being allocated a column is assigned 1 reference. When a counter reaches 0 the
1510 column is removed from occupied column set. Currently no counter becomes
1511 gather than 1, but leave_column method is written in generic way.
1513 Initialization is performed by reset_columns method. Column allocation is
1514 implemented in alloc_column method. Initialization and main loop are in
1515 recompute_columns method. Main loop also embeds row assignment algorithm by
1516 implementation. So, the algorithm initialization is also performed during
1517 recompute_grid method by calling reset_rows.
1519 Actions for each node are follow.
1520 1. If the node was not assigned a column then it is assigned empty one.
1521 2. Handle columns occupied by parents. Handling is leaving columns of some
1522 parents. One of parents occupies same column as the node. The column should not
1523 be left. Hence if the node is not a merge then nothing is done during the step.
1524 Other parents of merge node are processed in follow way.
1525 2.1. If parent is fork then a brother node could be at column of the
1526 parent. So, the column cannot be left. Note that the brother itself or one of
1527 its descendant will perform the column leaving at appropriate time.
1528 2.2 The parent may not occupy a column. This is possible when some commits
1529 were not added to the DAG (during repository reading, for instance). No column
1530 should be left.
1531 2.3. Leave column of the parent. The parent is a regular commit. Its
1532 outgoing edge is turned form its column to column of the node. Hence, the
1533 column is left.
1534 3. Get row for the node.
1535 4. Define columns and rows of children.
1536 4.1 If a child have a column assigned then it should no be overridden. One
1537 of children is assigned same column as the node. If the node is a fork then the
1538 child is chosen in generation descent order. This is a heuristic and it only
1539 affects resulting appearance of the graph. Other children are assigned empty
1540 columns in same order. It is the heuristic too.
1541 4.2 All children will got row during step 3 of its iteration. But frontier
1542 must be propagated during this iteration to meet first aim of the row
1543 assignment algorithm. Frontier of child that occupies same row was propagated
1544 during step 3. Hence, it must be propagated for children on side columns.
1546 After the algorithm was done all commit graphic items are assigned
1547 coordinates based on its row and column multiplied by the coefficient.
1550 def reset_columns(self):
1551 for node in self.commits:
1552 node.column = None
1553 self.columns = {}
1555 def reset_rows(self):
1556 self.frontier = {}
1557 self.tagged_cells = set()
1559 def declare_column(self, column):
1560 try:
1561 # This is heuristic that mostly affects roots. Note that the
1562 # frontier values for fork children will be overridden in course of
1563 # propagate_frontier.
1564 self.frontier[column] = self.frontier[column - 1] - 1
1565 except KeyError:
1566 # First commit must be assigned 0 row.
1567 self.frontier[column] = 0
1569 def alloc_column(self):
1570 columns = self.columns
1571 for c in count(0):
1572 if c not in columns:
1573 break
1574 self.declare_column(c)
1575 columns[c] = 1
1576 return c
1578 def alloc_cell(self, column, tags):
1579 # Get empty cell from frontier.
1580 cell_row = self.frontier[column]
1582 if tags:
1583 # Prevent overlapping with right cells. Do not occupy row if the
1584 # row is occupied by a commit at right side.
1585 for c in range(column + 1, len(self.frontier)):
1586 frontier = self.frontier[c]
1587 if frontier > cell_row:
1588 cell_row = frontier
1590 # Avoid overlapping with tags of left cells.
1591 # Sorting is a part for column overlapping check optimization.
1592 columns = sorted(range(0, column), key=lambda c: self.frontier[c])
1593 while columns:
1594 # Optimization. Remove columns which cannot contain overlapping
1595 # tags because all its commits are below.
1596 while columns:
1597 c = columns[0]
1598 if self.frontier[c] <= cell_row:
1599 # The column cannot overlap.
1600 columns.pop(0)
1601 else:
1602 # This column may overlap because the frontier is above.
1603 # Consequent columns may overlap too because columns
1604 # sorting criteria.
1605 break
1607 for c in columns:
1608 if (c, cell_row) in self.tagged_cells:
1609 # Overlapping. Try next row.
1610 cell_row += 1
1611 break
1612 else:
1613 # No overlapping was found.
1614 break
1615 # Note that all checks should be made for new cell_row value.
1617 if tags:
1618 self.tagged_cells.add((column, cell_row))
1620 # Propagate frontier.
1621 self.frontier[column] = cell_row + 1
1622 return cell_row
1624 def propagate_frontier(self, column, value):
1625 current = self.frontier[column]
1626 if current < value:
1627 self.frontier[column] = value
1629 def leave_column(self, column):
1630 count = self.columns[column]
1631 if count == 1:
1632 del self.columns[column]
1633 else:
1634 self.columns[column] = count - 1
1636 def recompute_columns(self):
1637 self.reset_columns()
1638 self.reset_rows()
1640 for node in self.sort_by_generation(list(self.commits)):
1641 if node.column is None:
1642 # Node is either root or its parent is not in items. The last
1643 # happens when tree loading is in progress. Allocate new
1644 # columns for such nodes.
1645 node.column = self.alloc_column()
1647 if node.is_merge():
1648 for parent in node.parents:
1649 if parent.is_fork():
1650 continue
1651 if parent.column == node.column:
1652 continue
1653 if parent.column is None:
1654 # Parent is in not among commits being layoutted, so it
1655 # have no column.
1656 continue
1657 self.leave_column(parent.column)
1659 node.row = self.alloc_cell(node.column, node.tags)
1661 # Propagate column to children which are still without one. Also
1662 # propagate frontier for children.
1663 if node.is_fork():
1664 sorted_children = sorted(node.children,
1665 key=lambda c: c.generation,
1666 reverse=True)
1667 citer = iter(sorted_children)
1668 for child in citer:
1669 if child.column is None:
1670 # Top most child occupies column of parent.
1671 child.column = node.column
1672 # Note that frontier is propagated in course of
1673 # alloc_cell.
1674 break
1675 else:
1676 self.propagate_frontier(child.column, node.row + 1)
1678 # Rest children are allocated new column.
1679 for child in citer:
1680 if child.column is None:
1681 child.column = self.alloc_column()
1682 self.propagate_frontier(child.column, node.row + 1)
1683 elif node.children:
1684 child = node.children[0]
1685 if child.column is None:
1686 child.column = node.column
1687 # Note that frontier is propagated in course of alloc_cell.
1688 elif child.column != node.column:
1689 # Child node have other parents and occupies side column.
1690 self.propagate_frontier(child.column, node.row + 1)
1692 def position_nodes(self):
1693 self.recompute_columns()
1695 x_max = self.x_max
1696 x_min = self.x_min
1697 x_off = self.x_off
1698 y_off = self.y_off
1699 y_min = y_off
1701 positions = {}
1703 for node in self.commits:
1704 x_pos = x_min + node.column * x_off
1705 y_pos = y_off + node.row * y_off
1707 positions[node.oid] = (x_pos, y_pos)
1709 x_max = max(x_max, x_pos)
1710 y_min = min(y_min, y_pos)
1712 self.x_max = x_max
1713 self.y_min = y_min
1715 return positions
1717 def update_scene_rect(self):
1718 y_min = self.y_min
1719 x_max = self.x_max
1720 self.scene().setSceneRect(-GraphView.x_adjust,
1721 y_min-GraphView.y_adjust,
1722 x_max + GraphView.x_adjust,
1723 abs(y_min) + GraphView.y_adjust)
1725 def sort_by_generation(self, commits):
1726 if len(commits) < 2:
1727 return commits
1728 commits.sort(key=lambda x: x.generation)
1729 return commits
1731 # Qt overrides
1732 def contextMenuEvent(self, event):
1733 self.context_menu_event(event)
1735 def mousePressEvent(self, event):
1736 if event.button() == Qt.MidButton:
1737 pos = event.pos()
1738 self.mouse_start = [pos.x(), pos.y()]
1739 self.saved_matrix = self.transform()
1740 self.is_panning = True
1741 return
1742 if event.button() == Qt.RightButton:
1743 event.ignore()
1744 return
1745 if event.button() == Qt.LeftButton:
1746 self.pressed = True
1747 self.handle_event(QtWidgets.QGraphicsView.mousePressEvent, event)
1749 def mouseMoveEvent(self, event):
1750 pos = self.mapToScene(event.pos())
1751 if self.is_panning:
1752 self.pan(event)
1753 return
1754 self.last_mouse[0] = pos.x()
1755 self.last_mouse[1] = pos.y()
1756 self.handle_event(QtWidgets.QGraphicsView.mouseMoveEvent, event)
1757 if self.pressed:
1758 self.viewport().repaint()
1760 def mouseReleaseEvent(self, event):
1761 self.pressed = False
1762 if event.button() == Qt.MidButton:
1763 self.is_panning = False
1764 return
1765 self.handle_event(QtWidgets.QGraphicsView.mouseReleaseEvent, event)
1766 self.selection_list = []
1767 self.viewport().repaint()
1769 def wheelEvent(self, event):
1770 """Handle Qt mouse wheel events."""
1771 if event.modifiers() & Qt.ControlModifier:
1772 self.wheel_zoom(event)
1773 else:
1774 self.wheel_pan(event)
1777 # Glossary
1778 # ========
1779 # oid -- Git objects IDs (i.e. SHA-1 IDs)
1780 # ref -- Git references that resolve to a commit-ish (HEAD, branches, tags)