text: defer calls to setStyleSheet()
[git-cola.git] / cola / widgets / dag.py
blob7831428be274312ae2623ab018412c6b7deb1949
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, settings=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, settings=settings)
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:
69 self._forward_action(name, *args, **kwargs))
71 def _forward_action(self, name, *args, **kwargs):
72 """Forward the captured action to the focused or default widget"""
73 widget = QtWidgets.QApplication.focusWidget()
74 if widget in self.widgets and hasattr(widget, name):
75 fn = getattr(widget, name)
76 else:
77 fn = getattr(self.default, name)
79 return fn(*args, **kwargs)
82 class ViewerMixin(object):
83 """Implementations must provide selected_items()"""
85 def __init__(self):
86 self.context = None # provided by implementation
87 self.selected = None
88 self.clicked = None
89 self.menu_actions = None # provided by implementation
91 def selected_item(self):
92 """Return the currently selected item"""
93 selected_items = self.selected_items()
94 if not selected_items:
95 return None
96 return selected_items[0]
98 def selected_oid(self):
99 item = self.selected_item()
100 if item is None:
101 result = None
102 else:
103 result = item.commit.oid
104 return result
106 def selected_oids(self):
107 return [i.commit for i in self.selected_items()]
109 def with_oid(self, fn):
110 oid = self.selected_oid()
111 if oid:
112 result = fn(oid)
113 else:
114 result = None
115 return result
117 def diff_selected_this(self):
118 clicked_oid = self.clicked.oid
119 selected_oid = self.selected.oid
120 self.diff_commits.emit(selected_oid, clicked_oid)
122 def diff_this_selected(self):
123 clicked_oid = self.clicked.oid
124 selected_oid = self.selected.oid
125 self.diff_commits.emit(clicked_oid, selected_oid)
127 def cherry_pick(self):
128 context = self.context
129 self.with_oid(lambda oid: cmds.do(cmds.CherryPick, context, [oid]))
131 def revert(self):
132 context = self.context
133 self.with_oid(lambda oid: cmds.do(cmds.Revert, context, oid))
135 def copy_to_clipboard(self):
136 self.with_oid(qtutils.set_clipboard)
138 def create_branch(self):
139 context = self.context
140 create_new_branch = partial(createbranch.create_new_branch, context)
141 self.with_oid(lambda oid: create_new_branch(revision=oid))
143 def create_tag(self):
144 context = self.context
145 self.with_oid(lambda oid: createtag.create_tag(context, ref=oid))
147 def create_tarball(self):
148 context = self.context
149 self.with_oid(
150 lambda oid: archive.show_save_dialog(context, oid, parent=self))
152 def show_diff(self):
153 context = self.context
154 self.with_oid(lambda oid:
155 difftool.diff_expression(context, self, oid + '^!',
156 hide_expr=False,
157 focus_tree=True))
159 def show_dir_diff(self):
160 context = self.context
161 self.with_oid(lambda oid:
162 cmds.difftool_launch(context, left=oid,
163 left_take_magic=True,
164 dir_diff=True))
166 def reset_branch_head(self):
167 context = self.context
168 self.with_oid(lambda oid:
169 cmds.do(cmds.ResetBranchHead, context, ref=oid))
171 def reset_worktree(self):
172 context = self.context
173 self.with_oid(lambda oid:
174 cmds.do(cmds.ResetWorktree, context, ref=oid))
176 def reset_merge(self):
177 context = self.context
178 self.with_oid(lambda oid: cmds.do(cmds.ResetMerge, context, ref=oid))
180 def reset_soft(self):
181 context = self.context
182 self.with_oid(lambda oid: cmds.do(cmds.ResetSoft, context, ref=oid))
184 def reset_hard(self):
185 context = self.context
186 self.with_oid(lambda oid: cmds.do(cmds.ResetHard, context, ref=oid))
188 def checkout_detached(self):
189 context = self.context
190 self.with_oid(lambda oid: cmds.do(cmds.Checkout, context, [oid]))
192 def save_blob_dialog(self):
193 context = self.context
194 self.with_oid(lambda oid: browse.BrowseBranch.browse(context, oid))
196 def update_menu_actions(self, event):
197 selected_items = self.selected_items()
198 item = self.itemAt(event.pos())
199 if item is None:
200 self.clicked = commit = None
201 else:
202 self.clicked = commit = item.commit
204 has_single_selection = len(selected_items) == 1
205 has_selection = bool(selected_items)
206 can_diff = bool(commit and has_single_selection and
207 commit is not selected_items[0].commit)
209 if can_diff:
210 self.selected = selected_items[0].commit
211 else:
212 self.selected = None
214 self.menu_actions['diff_this_selected'].setEnabled(can_diff)
215 self.menu_actions['diff_selected_this'].setEnabled(can_diff)
216 self.menu_actions['diff_commit'].setEnabled(has_single_selection)
217 self.menu_actions['diff_commit_all'].setEnabled(has_single_selection)
219 self.menu_actions['checkout_detached'].setEnabled(has_single_selection)
220 self.menu_actions['cherry_pick'].setEnabled(has_single_selection)
221 self.menu_actions['copy'].setEnabled(has_single_selection)
222 self.menu_actions['create_branch'].setEnabled(has_single_selection)
223 self.menu_actions['create_patch'].setEnabled(has_selection)
224 self.menu_actions['create_tag'].setEnabled(has_single_selection)
225 self.menu_actions['create_tarball'].setEnabled(has_single_selection)
226 self.menu_actions['reset_branch_head'].setEnabled(has_single_selection)
227 self.menu_actions['reset_worktree'].setEnabled(has_single_selection)
228 self.menu_actions['reset_merge'].setEnabled(has_single_selection)
229 self.menu_actions['reset_soft'].setEnabled(has_single_selection)
230 self.menu_actions['reset_hard'].setEnabled(has_single_selection)
231 self.menu_actions['revert'].setEnabled(has_single_selection)
232 self.menu_actions['save_blob'].setEnabled(has_single_selection)
234 def context_menu_event(self, event):
235 self.update_menu_actions(event)
236 menu = qtutils.create_menu(N_('Actions'), self)
237 menu.addAction(self.menu_actions['diff_this_selected'])
238 menu.addAction(self.menu_actions['diff_selected_this'])
239 menu.addAction(self.menu_actions['diff_commit'])
240 menu.addAction(self.menu_actions['diff_commit_all'])
241 menu.addSeparator()
242 menu.addAction(self.menu_actions['create_branch'])
243 menu.addAction(self.menu_actions['create_tag'])
244 menu.addSeparator()
245 menu.addAction(self.menu_actions['cherry_pick'])
246 menu.addAction(self.menu_actions['revert'])
247 menu.addAction(self.menu_actions['create_patch'])
248 menu.addAction(self.menu_actions['create_tarball'])
249 menu.addSeparator()
250 reset_menu = menu.addMenu(N_('Reset'))
251 reset_menu.addAction(self.menu_actions['reset_branch_head'])
252 reset_menu.addAction(self.menu_actions['reset_worktree'])
253 reset_menu.addSeparator()
254 reset_menu.addAction(self.menu_actions['reset_merge'])
255 reset_menu.addAction(self.menu_actions['reset_soft'])
256 reset_menu.addAction(self.menu_actions['reset_hard'])
257 menu.addAction(self.menu_actions['checkout_detached'])
258 menu.addSeparator()
259 menu.addAction(self.menu_actions['save_blob'])
260 menu.addAction(self.menu_actions['copy'])
261 menu.exec_(self.mapToGlobal(event.pos()))
264 def viewer_actions(widget):
265 return {
266 'diff_this_selected':
267 qtutils.add_action(widget, N_('Diff this -> selected'),
268 widget.proxy.diff_this_selected),
269 'diff_selected_this':
270 qtutils.add_action(widget, N_('Diff selected -> this'),
271 widget.proxy.diff_selected_this),
272 'create_branch':
273 qtutils.add_action(widget, N_('Create Branch'),
274 widget.proxy.create_branch),
275 'create_patch':
276 qtutils.add_action(widget, N_('Create Patch'),
277 widget.proxy.create_patch),
278 'create_tag':
279 qtutils.add_action(widget, N_('Create Tag'),
280 widget.proxy.create_tag),
281 'create_tarball':
282 qtutils.add_action(widget, N_('Save As Tarball/Zip...'),
283 widget.proxy.create_tarball),
284 'cherry_pick':
285 qtutils.add_action(widget, N_('Cherry Pick'),
286 widget.proxy.cherry_pick),
287 'revert':
288 qtutils.add_action(widget, N_('Revert'),
289 widget.proxy.revert),
290 'diff_commit':
291 qtutils.add_action(widget, N_('Launch Diff Tool'),
292 widget.proxy.show_diff, hotkeys.DIFF),
293 'diff_commit_all':
294 qtutils.add_action(widget, N_('Launch Directory Diff Tool'),
295 widget.proxy.show_dir_diff, hotkeys.DIFF_SECONDARY),
296 'checkout_detached':
297 qtutils.add_action(widget, N_('Checkout Detached HEAD'),
298 widget.proxy.checkout_detached),
299 'reset_branch_head':
300 qtutils.add_action(widget, N_('Reset Branch Head'),
301 widget.proxy.reset_branch_head),
302 'reset_worktree':
303 qtutils.add_action(widget, N_('Reset Worktree'),
304 widget.proxy.reset_worktree),
305 'reset_merge':
306 qtutils.add_action(widget, N_('Reset Merge'),
307 widget.proxy.reset_merge),
308 'reset_soft':
309 qtutils.add_action(widget, N_('Reset Soft'),
310 widget.proxy.reset_soft),
311 'reset_hard':
312 qtutils.add_action(widget, N_('Reset Hard'),
313 widget.proxy.reset_hard),
314 'save_blob':
315 qtutils.add_action(widget, N_('Grab File...'),
316 widget.proxy.save_blob_dialog),
317 'copy':
318 qtutils.add_action(widget, N_('Copy SHA-1'),
319 widget.proxy.copy_to_clipboard,
320 hotkeys.COPY_SHA1),
324 class CommitTreeWidgetItem(QtWidgets.QTreeWidgetItem):
326 def __init__(self, commit, parent=None):
327 QtWidgets.QTreeWidgetItem.__init__(self, parent)
328 self.commit = commit
329 self.setText(0, commit.summary)
330 self.setText(1, commit.author)
331 self.setText(2, commit.authdate)
334 class CommitTreeWidget(standard.TreeWidget, ViewerMixin):
336 diff_commits = Signal(object, object)
337 zoom_to_fit = Signal()
339 def __init__(self, context, notifier, parent):
340 standard.TreeWidget.__init__(self, parent)
341 ViewerMixin.__init__(self)
343 self.setSelectionMode(self.ExtendedSelection)
344 self.setHeaderLabels([N_('Summary'), N_('Author'), N_('Date, Time')])
346 self.context = context
347 self.oidmap = {}
348 self.menu_actions = None
349 self.notifier = notifier
350 self.selecting = False
351 self.commits = []
352 self._adjust_columns = False
354 self.action_up = qtutils.add_action(
355 self, N_('Go Up'), self.go_up, hotkeys.MOVE_UP)
357 self.action_down = qtutils.add_action(
358 self, N_('Go Down'), self.go_down, hotkeys.MOVE_DOWN)
360 self.zoom_to_fit_action = qtutils.add_action(
361 self, N_('Zoom to Fit'), self.zoom_to_fit.emit, hotkeys.FIT)
363 notifier.add_observer(diff.COMMITS_SELECTED, self.commits_selected)
365 self.itemSelectionChanged.connect(self.selection_changed)
367 def export_state(self):
368 """Export the widget's state"""
369 # The base class method is intentionally overridden because we only
370 # care about the details below for this subwidget.
371 state = {}
372 state['column_widths'] = self.column_widths()
373 return state
375 def apply_state(self, state):
376 """Apply the exported widget state"""
377 try:
378 column_widths = state['column_widths']
379 except (KeyError, ValueError):
380 column_widths = None
381 if column_widths:
382 self.set_column_widths(column_widths)
383 else:
384 # Defer showing the columns until we are shown, and our true width
385 # is known. Calling adjust_columns() here ends up with the wrong
386 # answer because we have not yet been parented to the layout.
387 # We set this flag that we process once during our initial
388 # showEvent().
389 self._adjust_columns = True
390 return True
392 # Qt overrides
393 def showEvent(self, event):
394 """Override QWidget::showEvent() to size columns when we are shown"""
395 if self._adjust_columns:
396 self._adjust_columns = False
397 width = self.width()
398 two_thirds = (width * 2) // 3
399 one_sixth = width // 6
401 self.setColumnWidth(0, two_thirds)
402 self.setColumnWidth(1, one_sixth)
403 self.setColumnWidth(2, one_sixth)
404 return standard.TreeWidget.showEvent(self, event)
406 # ViewerMixin
407 def go_up(self):
408 self.goto(self.itemAbove)
410 def go_down(self):
411 self.goto(self.itemBelow)
413 def goto(self, finder):
414 items = self.selected_items()
415 item = items[0] if items else None
416 if item is None:
417 return
418 found = finder(item)
419 if found:
420 self.select([found.commit.oid])
422 def selected_commit_range(self):
423 selected_items = self.selected_items()
424 if not selected_items:
425 return None, None
426 return selected_items[-1].commit.oid, selected_items[0].commit.oid
428 def set_selecting(self, selecting):
429 self.selecting = selecting
431 def selection_changed(self):
432 items = self.selected_items()
433 if not items:
434 return
435 self.set_selecting(True)
436 self.notifier.notify_observers(diff.COMMITS_SELECTED,
437 [i.commit for i in items])
438 self.set_selecting(False)
440 def commits_selected(self, commits):
441 if self.selecting:
442 return
443 with qtutils.BlockSignals(self):
444 self.select([commit.oid for commit in commits])
446 def select(self, oids):
447 if not oids:
448 return
449 self.clearSelection()
450 for oid in oids:
451 try:
452 item = self.oidmap[oid]
453 except KeyError:
454 continue
455 self.scrollToItem(item)
456 item.setSelected(True)
458 def clear(self):
459 QtWidgets.QTreeWidget.clear(self)
460 self.oidmap.clear()
461 self.commits = []
463 def add_commits(self, commits):
464 self.commits.extend(commits)
465 items = []
466 for c in reversed(commits):
467 item = CommitTreeWidgetItem(c)
468 items.append(item)
469 self.oidmap[c.oid] = item
470 for tag in c.tags:
471 self.oidmap[tag] = item
472 self.insertTopLevelItems(0, items)
474 def create_patch(self):
475 items = self.selectedItems()
476 if not items:
477 return
478 context = self.context
479 oids = [item.commit.oid for item in reversed(items)]
480 all_oids = [c.oid for c in self.commits]
481 cmds.do(cmds.FormatPatch, context, oids, all_oids)
483 # Qt overrides
484 def contextMenuEvent(self, event):
485 self.context_menu_event(event)
487 def mousePressEvent(self, event):
488 if event.button() == Qt.RightButton:
489 event.accept()
490 return
491 QtWidgets.QTreeWidget.mousePressEvent(self, event)
494 class GitDAG(standard.MainWindow):
495 """The git-dag widget."""
496 updated = Signal()
498 def __init__(self, context, params, parent=None, settings=None):
499 super(GitDAG, self).__init__(parent)
501 self.setMinimumSize(420, 420)
503 # change when widgets are added/removed
504 self.widget_version = 2
505 self.context = context
506 self.params = params
507 self.model = context.model
508 self.settings = settings
510 self.commits = {}
511 self.commit_list = []
512 self.selection = []
513 self.old_refs = set()
514 self.old_oids = None
515 self.old_count = 0
516 self.force_refresh = False
518 self.thread = None
519 self.revtext = completion.GitLogLineEdit(context)
520 self.maxresults = standard.SpinBox()
522 self.zoom_out = qtutils.create_action_button(
523 tooltip=N_('Zoom Out'), icon=icons.zoom_out())
525 self.zoom_in = qtutils.create_action_button(
526 tooltip=N_('Zoom In'), icon=icons.zoom_in())
528 self.zoom_to_fit = qtutils.create_action_button(
529 tooltip=N_('Zoom to Fit'), icon=icons.zoom_fit_best())
531 self.notifier = notifier = observable.Observable()
532 self.notifier.refs_updated = refs_updated = 'refs_updated'
533 self.notifier.add_observer(refs_updated, self.display)
534 self.notifier.add_observer(filelist.HISTORIES_SELECTED,
535 self.histories_selected)
536 self.notifier.add_observer(filelist.DIFFTOOL_SELECTED,
537 self.difftool_selected)
538 self.notifier.add_observer(diff.COMMITS_SELECTED, self.commits_selected)
540 self.treewidget = CommitTreeWidget(context, notifier, self)
541 self.diffwidget = diff.DiffWidget(context, notifier, self,
542 is_commit=True)
543 self.filewidget = filelist.FileWidget(context, notifier, self)
544 self.graphview = GraphView(context, notifier, self)
546 self.proxy = FocusRedirectProxy(self.treewidget,
547 self.graphview,
548 self.filewidget)
550 self.viewer_actions = actions = viewer_actions(self)
551 self.treewidget.menu_actions = actions
552 self.graphview.menu_actions = actions
554 self.controls_layout = qtutils.hbox(defs.no_margin, defs.spacing,
555 self.revtext, self.maxresults)
557 self.controls_widget = QtWidgets.QWidget()
558 self.controls_widget.setLayout(self.controls_layout)
560 self.log_dock = qtutils.create_dock(N_('Log'), self, stretch=False)
561 self.log_dock.setWidget(self.treewidget)
562 log_dock_titlebar = self.log_dock.titleBarWidget()
563 log_dock_titlebar.add_corner_widget(self.controls_widget)
565 self.file_dock = qtutils.create_dock(N_('Files'), self)
566 self.file_dock.setWidget(self.filewidget)
568 self.diff_dock = qtutils.create_dock(N_('Diff'), self)
569 self.diff_dock.setWidget(self.diffwidget)
571 self.graph_controls_layout = qtutils.hbox(
572 defs.no_margin, defs.button_spacing,
573 self.zoom_out, self.zoom_in, self.zoom_to_fit, defs.spacing)
575 self.graph_controls_widget = QtWidgets.QWidget()
576 self.graph_controls_widget.setLayout(self.graph_controls_layout)
578 self.graphview_dock = qtutils.create_dock(N_('Graph'), self)
579 self.graphview_dock.setWidget(self.graphview)
580 graph_titlebar = self.graphview_dock.titleBarWidget()
581 graph_titlebar.add_corner_widget(self.graph_controls_widget)
583 self.lock_layout_action = qtutils.add_action_bool(
584 self, N_('Lock Layout'), self.set_lock_layout, False)
586 self.refresh_action = qtutils.add_action(
587 self, N_('Refresh'), self.refresh, hotkeys.REFRESH)
589 # Create the application menu
590 self.menubar = QtWidgets.QMenuBar(self)
591 self.setMenuBar(self.menubar)
593 # View Menu
594 self.view_menu = qtutils.add_menu(N_('View'), self.menubar)
595 self.view_menu.addAction(self.refresh_action)
596 self.view_menu.addAction(self.log_dock.toggleViewAction())
597 self.view_menu.addAction(self.graphview_dock.toggleViewAction())
598 self.view_menu.addAction(self.diff_dock.toggleViewAction())
599 self.view_menu.addAction(self.file_dock.toggleViewAction())
600 self.view_menu.addSeparator()
601 self.view_menu.addAction(self.lock_layout_action)
603 left = Qt.LeftDockWidgetArea
604 right = Qt.RightDockWidgetArea
605 self.addDockWidget(left, self.log_dock)
606 self.addDockWidget(left, self.diff_dock)
607 self.addDockWidget(right, self.graphview_dock)
608 self.addDockWidget(right, self.file_dock)
610 # Also re-loads dag.* from the saved state
611 self.init_state(settings, self.resize_to_desktop)
613 qtutils.connect_button(self.zoom_out, self.graphview.zoom_out)
614 qtutils.connect_button(self.zoom_in, self.graphview.zoom_in)
615 qtutils.connect_button(self.zoom_to_fit,
616 self.graphview.zoom_to_fit)
618 self.treewidget.zoom_to_fit.connect(self.graphview.zoom_to_fit)
619 self.treewidget.diff_commits.connect(self.diff_commits)
620 self.graphview.diff_commits.connect(self.diff_commits)
621 self.filewidget.grab_file.connect(self.grab_file)
623 self.maxresults.editingFinished.connect(self.display)
625 self.revtext.textChanged.connect(self.text_changed)
626 self.revtext.activated.connect(self.display)
627 self.revtext.enter.connect(self.display)
628 self.revtext.down.connect(self.focus_tree)
630 # The model is updated in another thread so use
631 # signals/slots to bring control back to the main GUI thread
632 self.model.add_observer(self.model.message_updated, self.updated.emit)
633 self.updated.connect(self.model_updated, type=Qt.QueuedConnection)
635 qtutils.add_action(self, 'Focus', self.focus_input, hotkeys.FOCUS)
636 qtutils.add_close_action(self)
638 self.set_params(params)
640 def set_params(self, params):
641 context = self.context
642 self.params = params
644 # Update fields affected by model
645 self.revtext.setText(params.ref)
646 self.maxresults.setValue(params.count)
647 self.update_window_title()
649 if self.thread is not None:
650 self.thread.stop()
652 self.thread = ReaderThread(context, params, self)
654 thread = self.thread
655 thread.begin.connect(self.thread_begin, type=Qt.QueuedConnection)
656 thread.status.connect(self.thread_status, type=Qt.QueuedConnection)
657 thread.add.connect(self.add_commits, type=Qt.QueuedConnection)
658 thread.end.connect(self.thread_end, type=Qt.QueuedConnection)
660 def focus_input(self):
661 self.revtext.setFocus()
663 def focus_tree(self):
664 self.treewidget.setFocus()
666 def text_changed(self, txt):
667 self.params.ref = txt
668 self.update_window_title()
670 def update_window_title(self):
671 project = self.model.project
672 if self.params.ref:
673 self.setWindowTitle(N_('%(project)s: %(ref)s - DAG')
674 % dict(project=project, ref=self.params.ref))
675 else:
676 self.setWindowTitle(project + N_(' - DAG'))
678 def export_state(self):
679 state = standard.MainWindow.export_state(self)
680 state['count'] = self.params.count
681 state['log'] = self.treewidget.export_state()
682 return state
684 def apply_state(self, state):
685 result = standard.MainWindow.apply_state(self, state)
686 try:
687 count = state['count']
688 if self.params.overridden('count'):
689 count = self.params.count
690 except (KeyError, TypeError, ValueError, AttributeError):
691 count = self.params.count
692 result = False
693 self.params.set_count(count)
694 self.lock_layout_action.setChecked(state.get('lock_layout', False))
696 try:
697 log_state = state['log']
698 except (KeyError, ValueError):
699 log_state = None
700 if log_state:
701 self.treewidget.apply_state(log_state)
703 return result
705 def model_updated(self):
706 self.display()
708 def refresh(self):
709 """Unconditionally refresh the DAG"""
710 # self.force_refresh triggers an Unconditional redraw
711 self.force_refresh = True
712 cmds.do(cmds.Refresh, self.context)
713 self.force_refresh = False
715 def display(self):
716 """Update the view when the Git refs change"""
717 ref = get(self.revtext)
718 count = get(self.maxresults)
719 context = self.context
720 model = self.model
721 # The DAG tries to avoid updating when the object IDs have not
722 # changed. Without doing this the DAG constantly redraws itself
723 # whenever inotify sends update events, which hurts usability.
725 # To minimize redraws we leverage `git rev-parse`. The strategy is to
726 # use `git rev-parse` on the input line, which converts each argument
727 # into object IDs. From there it's a simple matter of detecting when
728 # the object IDs changed.
730 # In addition to object IDs, we also need to know when the set of
731 # named references (branches, tags) changes so that an update is
732 # triggered when new branches and tags are created.
733 refs = set(model.local_branches + model.remote_branches + model.tags)
734 argv = utils.shell_split(ref or 'HEAD')
735 oids = gitcmds.parse_refs(context, argv)
736 update = (self.force_refresh
737 or count != self.old_count
738 or oids != self.old_oids
739 or refs != self.old_refs)
740 if update:
741 self.thread.stop()
742 self.params.set_ref(ref)
743 self.params.set_count(count)
744 self.thread.start()
746 self.old_oids = oids
747 self.old_count = count
748 self.old_refs = refs
750 def commits_selected(self, commits):
751 if commits:
752 self.selection = commits
754 def clear(self):
755 self.commits.clear()
756 self.commit_list = []
757 self.graphview.clear()
758 self.treewidget.clear()
760 def add_commits(self, commits):
761 self.commit_list.extend(commits)
762 # Keep track of commits
763 for commit_obj in commits:
764 self.commits[commit_obj.oid] = commit_obj
765 for tag in commit_obj.tags:
766 self.commits[tag] = commit_obj
767 self.graphview.add_commits(commits)
768 self.treewidget.add_commits(commits)
770 def thread_begin(self):
771 self.clear()
773 def thread_end(self):
774 self.restore_selection()
776 def thread_status(self, successful):
777 self.revtext.hint.set_error(not successful)
779 def restore_selection(self):
780 selection = self.selection
781 try:
782 commit_obj = self.commit_list[-1]
783 except IndexError:
784 # No commits, exist, early-out
785 return
787 new_commits = [self.commits.get(s.oid, None) for s in selection]
788 new_commits = [c for c in new_commits if c is not None]
789 if new_commits:
790 # The old selection exists in the new state
791 self.notifier.notify_observers(diff.COMMITS_SELECTED, new_commits)
792 else:
793 # The old selection is now empty. Select the top-most commit
794 self.notifier.notify_observers(diff.COMMITS_SELECTED, [commit_obj])
796 self.graphview.set_initial_view()
798 def diff_commits(self, a, b):
799 paths = self.params.paths()
800 if paths:
801 cmds.difftool_launch(self.context, left=a, right=b, paths=paths)
802 else:
803 difftool.diff_commits(self.context, self, a, b)
805 # Qt overrides
806 def closeEvent(self, event):
807 self.revtext.close_popup()
808 self.thread.stop()
809 standard.MainWindow.closeEvent(self, event)
811 def histories_selected(self, histories):
812 argv = [self.model.currentbranch, '--']
813 argv.extend(histories)
814 text = core.list2cmdline(argv)
815 self.revtext.setText(text)
816 self.display()
818 def difftool_selected(self, files):
819 bottom, top = self.treewidget.selected_commit_range()
820 if not top:
821 return
822 cmds.difftool_launch(self.context, left=bottom, left_take_parent=True,
823 right=top, paths=files)
825 def grab_file(self, filename):
826 """Save the selected file from the filelist widget"""
827 oid = self.treewidget.selected_oid()
828 model = browse.BrowseModel(oid, filename=filename)
829 browse.save_path(self.context, filename, model)
832 class ReaderThread(QtCore.QThread):
833 begin = Signal()
834 add = Signal(object)
835 end = Signal()
836 status = Signal(object)
838 def __init__(self, context, params, parent):
839 QtCore.QThread.__init__(self, parent)
840 self.context = context
841 self.params = params
842 self._abort = False
843 self._stop = False
844 self._mutex = QtCore.QMutex()
845 self._condition = QtCore.QWaitCondition()
847 def run(self):
848 context = self.context
849 repo = dag.RepoReader(context, self.params)
850 repo.reset()
851 self.begin.emit()
852 commits = []
853 for c in repo.get():
854 self._mutex.lock()
855 if self._stop:
856 self._condition.wait(self._mutex)
857 self._mutex.unlock()
858 if self._abort:
859 repo.reset()
860 return
861 commits.append(c)
862 if len(commits) >= 512:
863 self.add.emit(commits)
864 commits = []
866 self.status.emit(repo.returncode == 0)
867 if commits:
868 self.add.emit(commits)
869 self.end.emit()
871 def start(self):
872 self._abort = False
873 self._stop = False
874 QtCore.QThread.start(self)
876 def pause(self):
877 self._mutex.lock()
878 self._stop = True
879 self._mutex.unlock()
881 def resume(self):
882 self._mutex.lock()
883 self._stop = False
884 self._mutex.unlock()
885 self._condition.wakeOne()
887 def stop(self):
888 self._abort = True
889 self.wait()
892 class Cache(object):
894 _label_font = None
896 @classmethod
897 def label_font(cls):
898 font = cls._label_font
899 if font is None:
900 font = cls._label_font = QtWidgets.QApplication.font()
901 font.setPointSize(6)
902 return font
905 class Edge(QtWidgets.QGraphicsItem):
906 item_type = QtWidgets.QGraphicsItem.UserType + 1
908 def __init__(self, source, dest):
910 QtWidgets.QGraphicsItem.__init__(self)
912 self.setAcceptedMouseButtons(Qt.NoButton)
913 self.source = source
914 self.dest = dest
915 self.commit = source.commit
916 self.setZValue(-2)
918 self.recompute_bound()
919 self.path = None
920 self.path_valid = False
922 # Choose a new color for new branch edges
923 if self.source.x() < self.dest.x():
924 color = EdgeColor.cycle()
925 line = Qt.SolidLine
926 elif self.source.x() != self.dest.x():
927 color = EdgeColor.current()
928 line = Qt.SolidLine
929 else:
930 color = EdgeColor.current()
931 line = Qt.SolidLine
933 self.pen = QtGui.QPen(color, 4.0, line, Qt.SquareCap, Qt.RoundJoin)
935 def recompute_bound(self):
936 dest_pt = Commit.item_bbox.center()
938 self.source_pt = self.mapFromItem(self.source, dest_pt)
939 self.dest_pt = self.mapFromItem(self.dest, dest_pt)
940 self.line = QtCore.QLineF(self.source_pt, self.dest_pt)
942 width = self.dest_pt.x() - self.source_pt.x()
943 height = self.dest_pt.y() - self.source_pt.y()
944 rect = QtCore.QRectF(self.source_pt, QtCore.QSizeF(width, height))
945 self.bound = rect.normalized()
947 def commits_were_invalidated(self):
948 self.recompute_bound()
949 self.prepareGeometryChange()
950 # The path should not be recomputed immediately because just small part
951 # of DAG is actually shown at same time. It will be recomputed on
952 # demand in course of 'paint' method.
953 self.path_valid = False
954 # Hence, just queue redrawing.
955 self.update()
957 # Qt overrides
958 def type(self):
959 return self.item_type
961 def boundingRect(self):
962 return self.bound
964 def recompute_path(self):
965 QRectF = QtCore.QRectF
966 QPointF = QtCore.QPointF
968 arc_rect = 10
969 connector_length = 5
971 path = QtGui.QPainterPath()
973 if self.source.x() == self.dest.x():
974 path.moveTo(self.source.x(), self.source.y())
975 path.lineTo(self.dest.x(), self.dest.y())
976 else:
977 # Define points starting from source
978 point1 = QPointF(self.source.x(), self.source.y())
979 point2 = QPointF(point1.x(), point1.y() - connector_length)
980 point3 = QPointF(point2.x() + arc_rect, point2.y() - arc_rect)
982 # Define points starting from dest
983 point4 = QPointF(self.dest.x(), self.dest.y())
984 point5 = QPointF(point4.x(), point3.y() - arc_rect)
985 point6 = QPointF(point5.x() - arc_rect, point5.y() + arc_rect)
987 start_angle_arc1 = 180
988 span_angle_arc1 = 90
989 start_angle_arc2 = 90
990 span_angle_arc2 = -90
992 # If the dest is at the left of the source, then we
993 # need to reverse some values
994 if self.source.x() > self.dest.x():
995 point5 = QPointF(point4.x(), point4.y() + connector_length)
996 point6 = QPointF(point5.x() + arc_rect, point5.y() + arc_rect)
997 point3 = QPointF(self.source.x() - arc_rect, point6.y())
998 point2 = QPointF(self.source.x(), point3.y() + arc_rect)
1000 span_angle_arc1 = 90
1002 path.moveTo(point1)
1003 path.lineTo(point2)
1004 path.arcTo(QRectF(point2, point3),
1005 start_angle_arc1, span_angle_arc1)
1006 path.lineTo(point6)
1007 path.arcTo(QRectF(point6, point5),
1008 start_angle_arc2, span_angle_arc2)
1009 path.lineTo(point4)
1011 self.path = path
1012 self.path_valid = True
1014 def paint(self, painter, _option, _widget):
1015 if not self.path_valid:
1016 self.recompute_path()
1017 painter.setPen(self.pen)
1018 painter.drawPath(self.path)
1021 class EdgeColor(object):
1022 """An edge color factory"""
1024 current_color_index = 0
1025 colors = [
1026 QtGui.QColor(Qt.red),
1027 QtGui.QColor(Qt.green),
1028 QtGui.QColor(Qt.blue),
1029 QtGui.QColor(Qt.black),
1030 QtGui.QColor(Qt.darkRed),
1031 QtGui.QColor(Qt.darkGreen),
1032 QtGui.QColor(Qt.darkBlue),
1033 QtGui.QColor(Qt.cyan),
1034 QtGui.QColor(Qt.magenta),
1035 # Orange; Qt.yellow is too low-contrast
1036 qtutils.rgba(0xff, 0x66, 0x00),
1037 QtGui.QColor(Qt.gray),
1038 QtGui.QColor(Qt.darkCyan),
1039 QtGui.QColor(Qt.darkMagenta),
1040 QtGui.QColor(Qt.darkYellow),
1041 QtGui.QColor(Qt.darkGray),
1044 @classmethod
1045 def cycle(cls):
1046 cls.current_color_index += 1
1047 cls.current_color_index %= len(cls.colors)
1048 color = cls.colors[cls.current_color_index]
1049 color.setAlpha(128)
1050 return color
1052 @classmethod
1053 def current(cls):
1054 return cls.colors[cls.current_color_index]
1056 @classmethod
1057 def reset(cls):
1058 cls.current_color_index = 0
1061 class Commit(QtWidgets.QGraphicsItem):
1062 item_type = QtWidgets.QGraphicsItem.UserType + 2
1063 commit_radius = 12.0
1064 merge_radius = 18.0
1066 item_shape = QtGui.QPainterPath()
1067 item_shape.addRect(commit_radius/-2.0,
1068 commit_radius/-2.0,
1069 commit_radius, commit_radius)
1070 item_bbox = item_shape.boundingRect()
1072 inner_rect = QtGui.QPainterPath()
1073 inner_rect.addRect(commit_radius/-2.0 + 2.0,
1074 commit_radius/-2.0 + 2.0,
1075 commit_radius - 4.0,
1076 commit_radius - 4.0)
1077 inner_rect = inner_rect.boundingRect()
1079 commit_color = QtGui.QColor(Qt.white)
1080 outline_color = commit_color.darker()
1081 merge_color = QtGui.QColor(Qt.lightGray)
1083 commit_selected_color = QtGui.QColor(Qt.green)
1084 selected_outline_color = commit_selected_color.darker()
1086 commit_pen = QtGui.QPen()
1087 commit_pen.setWidth(1.0)
1088 commit_pen.setColor(outline_color)
1090 def __init__(self, commit,
1091 notifier,
1092 selectable=QtWidgets.QGraphicsItem.ItemIsSelectable,
1093 cursor=Qt.PointingHandCursor,
1094 xpos=commit_radius/2.0 + 1.0,
1095 cached_commit_color=commit_color,
1096 cached_merge_color=merge_color):
1098 QtWidgets.QGraphicsItem.__init__(self)
1100 self.commit = commit
1101 self.notifier = notifier
1102 self.selected = False
1104 self.setZValue(0)
1105 self.setFlag(selectable)
1106 self.setCursor(cursor)
1107 self.setToolTip(commit.oid[:12] + ': ' + commit.summary)
1109 if commit.tags:
1110 self.label = label = Label(commit)
1111 label.setParentItem(self)
1112 label.setPos(xpos + 1, -self.commit_radius/2.0)
1113 else:
1114 self.label = None
1116 if len(commit.parents) > 1:
1117 self.brush = cached_merge_color
1118 else:
1119 self.brush = cached_commit_color
1121 self.pressed = False
1122 self.dragged = False
1124 self.edges = {}
1126 def blockSignals(self, blocked):
1127 self.notifier.notification_enabled = not blocked
1129 def itemChange(self, change, value):
1130 if change == QtWidgets.QGraphicsItem.ItemSelectedHasChanged:
1131 # Broadcast selection to other widgets
1132 selected_items = self.scene().selectedItems()
1133 commits = [item.commit for item in selected_items]
1134 self.scene().parent().set_selecting(True)
1135 self.notifier.notify_observers(diff.COMMITS_SELECTED, commits)
1136 self.scene().parent().set_selecting(False)
1138 # Cache the pen for use in paint()
1139 if value:
1140 self.brush = self.commit_selected_color
1141 color = self.selected_outline_color
1142 else:
1143 if len(self.commit.parents) > 1:
1144 self.brush = self.merge_color
1145 else:
1146 self.brush = self.commit_color
1147 color = self.outline_color
1148 commit_pen = QtGui.QPen()
1149 commit_pen.setWidth(1.0)
1150 commit_pen.setColor(color)
1151 self.commit_pen = commit_pen
1153 return QtWidgets.QGraphicsItem.itemChange(self, change, value)
1155 def type(self):
1156 return self.item_type
1158 def boundingRect(self):
1159 return self.item_bbox
1161 def shape(self):
1162 return self.item_shape
1164 def paint(self, painter, option, _widget):
1166 # Do not draw outside the exposed rect
1167 painter.setClipRect(option.exposedRect)
1169 # Draw ellipse
1170 painter.setPen(self.commit_pen)
1171 painter.setBrush(self.brush)
1172 painter.drawEllipse(self.inner_rect)
1174 def mousePressEvent(self, event):
1175 QtWidgets.QGraphicsItem.mousePressEvent(self, event)
1176 self.pressed = True
1177 self.selected = self.isSelected()
1179 def mouseMoveEvent(self, event):
1180 if self.pressed:
1181 self.dragged = True
1182 QtWidgets.QGraphicsItem.mouseMoveEvent(self, event)
1184 def mouseReleaseEvent(self, event):
1185 QtWidgets.QGraphicsItem.mouseReleaseEvent(self, event)
1186 if (not self.dragged and
1187 self.selected and
1188 event.button() == Qt.LeftButton):
1189 return
1190 self.pressed = False
1191 self.dragged = False
1194 class Label(QtWidgets.QGraphicsItem):
1196 item_type = QtWidgets.QGraphicsItem.UserType + 3
1198 head_color = QtGui.QColor(Qt.green)
1199 other_color = QtGui.QColor(Qt.white)
1200 remote_color = QtGui.QColor(Qt.yellow)
1202 head_pen = QtGui.QPen()
1203 head_pen.setColor(head_color.darker().darker())
1204 head_pen.setWidth(1.0)
1206 text_pen = QtGui.QPen()
1207 text_pen.setColor(QtGui.QColor(Qt.darkGray))
1208 text_pen.setWidth(1.0)
1210 alpha = 180
1211 head_color.setAlpha(alpha)
1212 other_color.setAlpha(alpha)
1213 remote_color.setAlpha(alpha)
1215 border = 2
1216 item_spacing = 5
1217 text_offset = 1
1219 def __init__(self, commit):
1220 QtWidgets.QGraphicsItem.__init__(self)
1221 self.setZValue(-1)
1222 self.commit = commit
1224 def type(self):
1225 return self.item_type
1227 def boundingRect(self, cache=Cache):
1228 QPainterPath = QtGui.QPainterPath
1229 QRectF = QtCore.QRectF
1231 width = 72
1232 height = 18
1233 current_width = 0
1234 spacing = self.item_spacing
1235 border = self.border + self.text_offset # text offset=1 in paint()
1237 font = cache.label_font()
1238 item_shape = QPainterPath()
1240 base_rect = QRectF(0, 0, width, height)
1241 base_rect = base_rect.adjusted(-border, -border, border, border)
1242 item_shape.addRect(base_rect)
1244 for tag in self.commit.tags:
1245 text_shape = QPainterPath()
1246 text_shape.addText(current_width, 0, font, tag)
1247 text_rect = text_shape.boundingRect()
1248 box_rect = text_rect.adjusted(-border, -border, border, border)
1249 item_shape.addRect(box_rect)
1250 current_width = item_shape.boundingRect().width() + spacing
1252 return item_shape.boundingRect()
1254 def paint(self, painter, _option, _widget, cache=Cache):
1255 # Draw tags and branches
1256 font = cache.label_font()
1257 painter.setFont(font)
1259 current_width = 0
1260 border = self.border
1261 offset = self.text_offset
1262 spacing = self.item_spacing
1263 QRectF = QtCore.QRectF
1265 HEAD = 'HEAD'
1266 remotes_prefix = 'remotes/'
1267 tags_prefix = 'tags/'
1268 heads_prefix = 'heads/'
1269 remotes_len = len(remotes_prefix)
1270 tags_len = len(tags_prefix)
1271 heads_len = len(heads_prefix)
1273 for tag in self.commit.tags:
1274 if tag == HEAD:
1275 painter.setPen(self.text_pen)
1276 painter.setBrush(self.remote_color)
1277 elif tag.startswith(remotes_prefix):
1278 tag = tag[remotes_len:]
1279 painter.setPen(self.text_pen)
1280 painter.setBrush(self.other_color)
1281 elif tag.startswith(tags_prefix):
1282 tag = tag[tags_len:]
1283 painter.setPen(self.text_pen)
1284 painter.setBrush(self.remote_color)
1285 elif tag.startswith(heads_prefix):
1286 tag = tag[heads_len:]
1287 painter.setPen(self.head_pen)
1288 painter.setBrush(self.head_color)
1289 else:
1290 painter.setPen(self.text_pen)
1291 painter.setBrush(self.other_color)
1293 text_rect = painter.boundingRect(
1294 QRectF(current_width, 0, 0, 0), Qt.TextSingleLine, tag)
1295 box_rect = text_rect.adjusted(-offset, -offset, offset, offset)
1297 painter.drawRoundedRect(box_rect, border, border)
1298 painter.drawText(text_rect, Qt.TextSingleLine, tag)
1299 current_width += text_rect.width() + spacing
1302 class GraphView(QtWidgets.QGraphicsView, ViewerMixin):
1304 diff_commits = Signal(object, object)
1306 x_adjust = int(Commit.commit_radius*4/3)
1307 y_adjust = int(Commit.commit_radius*4/3)
1309 x_off = -18
1310 y_off = -24
1312 def __init__(self, context, notifier, parent):
1313 QtWidgets.QGraphicsView.__init__(self, parent)
1314 ViewerMixin.__init__(self)
1316 highlight = self.palette().color(QtGui.QPalette.Highlight)
1317 Commit.commit_selected_color = highlight
1318 Commit.selected_outline_color = highlight.darker()
1320 self.context = context
1321 self.columns = {}
1322 self.selection_list = []
1323 self.menu_actions = None
1324 self.notifier = notifier
1325 self.commits = []
1326 self.items = {}
1327 self.mouse_start = [0, 0]
1328 self.saved_matrix = self.transform()
1329 self.max_column = 0
1330 self.min_column = 0
1331 self.frontier = {}
1332 self.tagged_cells = set()
1334 self.x_start = 24
1335 self.x_min = 24
1336 self.x_offsets = collections.defaultdict(lambda: self.x_min)
1338 self.is_panning = False
1339 self.pressed = False
1340 self.selecting = False
1341 self.last_mouse = [0, 0]
1342 self.zoom = 2
1343 self.setDragMode(self.RubberBandDrag)
1345 scene = QtWidgets.QGraphicsScene(self)
1346 scene.setItemIndexMethod(QtWidgets.QGraphicsScene.NoIndex)
1347 self.setScene(scene)
1349 self.setRenderHint(QtGui.QPainter.Antialiasing)
1350 self.setViewportUpdateMode(self.BoundingRectViewportUpdate)
1351 self.setCacheMode(QtWidgets.QGraphicsView.CacheBackground)
1352 self.setTransformationAnchor(QtWidgets.QGraphicsView.AnchorUnderMouse)
1353 self.setResizeAnchor(QtWidgets.QGraphicsView.NoAnchor)
1354 self.setBackgroundBrush(QtGui.QColor(Qt.white))
1356 qtutils.add_action(self, N_('Zoom In'), self.zoom_in,
1357 hotkeys.ZOOM_IN, hotkeys.ZOOM_IN_SECONDARY)
1359 qtutils.add_action(self, N_('Zoom Out'), self.zoom_out,
1360 hotkeys.ZOOM_OUT)
1362 qtutils.add_action(self, N_('Zoom to Fit'),
1363 self.zoom_to_fit, hotkeys.FIT)
1365 qtutils.add_action(self, N_('Select Parent'),
1366 self._select_parent, hotkeys.MOVE_DOWN_TERTIARY)
1368 qtutils.add_action(self, N_('Select Oldest Parent'),
1369 self._select_oldest_parent, hotkeys.MOVE_DOWN)
1371 qtutils.add_action(self, N_('Select Child'),
1372 self._select_child, hotkeys.MOVE_UP_TERTIARY)
1374 qtutils.add_action(self, N_('Select Newest Child'),
1375 self._select_newest_child, hotkeys.MOVE_UP)
1377 notifier.add_observer(diff.COMMITS_SELECTED, self.commits_selected)
1379 def clear(self):
1380 EdgeColor.reset()
1381 self.scene().clear()
1382 self.selection_list = []
1383 self.items.clear()
1384 self.x_offsets.clear()
1385 self.x_min = 24
1386 self.commits = []
1388 # ViewerMixin interface
1389 def selected_items(self):
1390 """Return the currently selected items"""
1391 return self.scene().selectedItems()
1393 def zoom_in(self):
1394 self.scale_view(1.5)
1396 def zoom_out(self):
1397 self.scale_view(1.0/1.5)
1399 def commits_selected(self, commits):
1400 if self.selecting:
1401 return
1402 self.select([commit.oid for commit in commits])
1404 def select(self, oids):
1405 """Select the item for the oids"""
1406 self.scene().clearSelection()
1407 for oid in oids:
1408 try:
1409 item = self.items[oid]
1410 except KeyError:
1411 continue
1412 item.blockSignals(True)
1413 item.setSelected(True)
1414 item.blockSignals(False)
1415 item_rect = item.sceneTransform().mapRect(item.boundingRect())
1416 self.ensureVisible(item_rect)
1418 def _get_item_by_generation(self, commits, criteria_fn):
1419 """Return the item for the commit matching criteria"""
1420 if not commits:
1421 return None
1422 generation = None
1423 for commit in commits:
1424 if (generation is None or
1425 criteria_fn(generation, commit.generation)):
1426 oid = commit.oid
1427 generation = commit.generation
1428 try:
1429 return self.items[oid]
1430 except KeyError:
1431 return None
1433 def _oldest_item(self, commits):
1434 """Return the item for the commit with the oldest generation number"""
1435 return self._get_item_by_generation(commits, lambda a, b: a > b)
1437 def _newest_item(self, commits):
1438 """Return the item for the commit with the newest generation number"""
1439 return self._get_item_by_generation(commits, lambda a, b: a < b)
1441 def create_patch(self):
1442 items = self.selected_items()
1443 if not items:
1444 return
1445 context = self.context
1446 selected_commits = sort_by_generation([n.commit for n in items])
1447 oids = [c.oid for c in selected_commits]
1448 all_oids = [c.oid for c in self.commits]
1449 cmds.do(cmds.FormatPatch, context, oids, all_oids)
1451 def _select_parent(self):
1452 """Select the parent with the newest generation number"""
1453 selected_item = self.selected_item()
1454 if selected_item is None:
1455 return
1456 parent_item = self._newest_item(selected_item.commit.parents)
1457 if parent_item is None:
1458 return
1459 selected_item.setSelected(False)
1460 parent_item.setSelected(True)
1461 self.ensureVisible(
1462 parent_item.mapRectToScene(parent_item.boundingRect()))
1464 def _select_oldest_parent(self):
1465 """Select the parent with the oldest generation number"""
1466 selected_item = self.selected_item()
1467 if selected_item is None:
1468 return
1469 parent_item = self._oldest_item(selected_item.commit.parents)
1470 if parent_item is None:
1471 return
1472 selected_item.setSelected(False)
1473 parent_item.setSelected(True)
1474 scene_rect = parent_item.mapRectToScene(parent_item.boundingRect())
1475 self.ensureVisible(scene_rect)
1477 def _select_child(self):
1478 """Select the child with the oldest generation number"""
1479 selected_item = self.selected_item()
1480 if selected_item is None:
1481 return
1482 child_item = self._oldest_item(selected_item.commit.children)
1483 if child_item is None:
1484 return
1485 selected_item.setSelected(False)
1486 child_item.setSelected(True)
1487 scene_rect = child_item.mapRectToScene(child_item.boundingRect())
1488 self.ensureVisible(scene_rect)
1490 def _select_newest_child(self):
1491 """Select the Nth child with the newest generation number (N > 1)"""
1492 selected_item = self.selected_item()
1493 if selected_item is None:
1494 return
1495 if len(selected_item.commit.children) > 1:
1496 children = selected_item.commit.children[1:]
1497 else:
1498 children = selected_item.commit.children
1499 child_item = self._newest_item(children)
1500 if child_item is None:
1501 return
1502 selected_item.setSelected(False)
1503 child_item.setSelected(True)
1504 scene_rect = child_item.mapRectToScene(child_item.boundingRect())
1505 self.ensureVisible(scene_rect)
1507 def set_initial_view(self):
1508 self_commits = self.commits
1509 self_items = self.items
1511 commits = self_commits[-7:]
1512 items = [self_items[c.oid] for c in commits]
1514 selected = self.selected_items()
1515 if selected:
1516 items.extend(selected)
1518 self.fit_view_to_items(items)
1520 def zoom_to_fit(self):
1521 """Fit selected items into the viewport"""
1523 items = self.selected_items()
1524 self.fit_view_to_items(items)
1526 def fit_view_to_items(self, items):
1527 if not items:
1528 rect = self.scene().itemsBoundingRect()
1529 else:
1530 x_min = y_min = maxsize
1531 x_max = y_max = -maxsize
1533 for item in items:
1534 pos = item.pos()
1535 x = pos.x()
1536 y = pos.y()
1537 x_min = min(x_min, x)
1538 x_max = max(x_max, x)
1539 y_min = min(y_min, y)
1540 y_max = max(y_max, y)
1542 rect = QtCore.QRectF(x_min, y_min,
1543 abs(x_max - x_min),
1544 abs(y_max - y_min))
1546 x_adjust = abs(GraphView.x_adjust)
1547 y_adjust = abs(GraphView.y_adjust)
1549 count = max(2.0, 10.0 - len(items)/2.0)
1550 y_offset = int(y_adjust * count)
1551 x_offset = int(x_adjust * count)
1552 rect.setX(rect.x() - x_offset//2)
1553 rect.setY(rect.y() - y_adjust//2)
1554 rect.setHeight(rect.height() + y_offset)
1555 rect.setWidth(rect.width() + x_offset)
1557 self.fitInView(rect, Qt.KeepAspectRatio)
1558 self.scene().invalidate()
1560 def save_selection(self, event):
1561 if event.button() != Qt.LeftButton:
1562 return
1563 elif Qt.ShiftModifier != event.modifiers():
1564 return
1565 self.selection_list = self.selected_items()
1567 def restore_selection(self, event):
1568 if Qt.ShiftModifier != event.modifiers():
1569 return
1570 for item in self.selection_list:
1571 item.setSelected(True)
1573 def handle_event(self, event_handler, event):
1574 self.save_selection(event)
1575 event_handler(self, event)
1576 self.restore_selection(event)
1577 self.update()
1579 def set_selecting(self, selecting):
1580 self.selecting = selecting
1582 def pan(self, event):
1583 pos = event.pos()
1584 dx = pos.x() - self.mouse_start[0]
1585 dy = pos.y() - self.mouse_start[1]
1587 if dx == 0 and dy == 0:
1588 return
1590 rect = QtCore.QRect(0, 0, abs(dx), abs(dy))
1591 delta = self.mapToScene(rect).boundingRect()
1593 tx = delta.width()
1594 if dx < 0.0:
1595 tx = -tx
1597 ty = delta.height()
1598 if dy < 0.0:
1599 ty = -ty
1601 matrix = self.transform()
1602 matrix.reset()
1603 matrix *= self.saved_matrix
1604 matrix.translate(tx, ty)
1606 self.setTransformationAnchor(QtWidgets.QGraphicsView.NoAnchor)
1607 self.setTransform(matrix)
1609 def wheel_zoom(self, event):
1610 """Handle mouse wheel zooming."""
1611 delta = qtcompat.wheel_delta(event)
1612 zoom = math.pow(2.0, delta/512.0)
1613 factor = (self.transform()
1614 .scale(zoom, zoom)
1615 .mapRect(QtCore.QRectF(0.0, 0.0, 1.0, 1.0))
1616 .width())
1617 if factor < 0.014 or factor > 42.0:
1618 return
1619 self.setTransformationAnchor(QtWidgets.QGraphicsView.AnchorUnderMouse)
1620 self.zoom = zoom
1621 self.scale(zoom, zoom)
1623 def wheel_pan(self, event):
1624 """Handle mouse wheel panning."""
1625 unit = QtCore.QRectF(0.0, 0.0, 1.0, 1.0)
1626 factor = 1.0 / self.transform().mapRect(unit).width()
1627 tx, ty = qtcompat.wheel_translation(event)
1629 matrix = self.transform().translate(tx * factor, ty * factor)
1630 self.setTransformationAnchor(QtWidgets.QGraphicsView.NoAnchor)
1631 self.setTransform(matrix)
1633 def scale_view(self, scale):
1634 factor = (self.transform()
1635 .scale(scale, scale)
1636 .mapRect(QtCore.QRectF(0, 0, 1, 1))
1637 .width())
1638 if factor < 0.07 or factor > 100.0:
1639 return
1640 self.zoom = scale
1642 adjust_scrollbars = True
1643 scrollbar = self.verticalScrollBar()
1644 if scrollbar:
1645 value = get(scrollbar)
1646 min_ = scrollbar.minimum()
1647 max_ = scrollbar.maximum()
1648 range_ = max_ - min_
1649 distance = value - min_
1650 nonzero_range = range_ > 0.1
1651 if nonzero_range:
1652 scrolloffset = distance/range_
1653 else:
1654 adjust_scrollbars = False
1656 self.setTransformationAnchor(QtWidgets.QGraphicsView.NoAnchor)
1657 self.scale(scale, scale)
1659 scrollbar = self.verticalScrollBar()
1660 if scrollbar and adjust_scrollbars:
1661 min_ = scrollbar.minimum()
1662 max_ = scrollbar.maximum()
1663 range_ = max_ - min_
1664 value = min_ + int(float(range_) * scrolloffset)
1665 scrollbar.setValue(value)
1667 def add_commits(self, commits):
1668 """Traverse commits and add them to the view."""
1669 self.commits.extend(commits)
1670 scene = self.scene()
1671 for commit in commits:
1672 item = Commit(commit, self.notifier)
1673 self.items[commit.oid] = item
1674 for ref in commit.tags:
1675 self.items[ref] = item
1676 scene.addItem(item)
1678 self.layout_commits()
1679 self.link(commits)
1681 def link(self, commits):
1682 """Create edges linking commits with their parents"""
1683 scene = self.scene()
1684 for commit in commits:
1685 try:
1686 commit_item = self.items[commit.oid]
1687 except KeyError:
1688 # TODO - Handle truncated history viewing
1689 continue
1690 for parent in reversed(commit.parents):
1691 try:
1692 parent_item = self.items[parent.oid]
1693 except KeyError:
1694 # TODO - Handle truncated history viewing
1695 continue
1696 try:
1697 edge = parent_item.edges[commit.oid]
1698 except KeyError:
1699 edge = Edge(parent_item, commit_item)
1700 else:
1701 continue
1702 parent_item.edges[commit.oid] = edge
1703 commit_item.edges[parent.oid] = edge
1704 scene.addItem(edge)
1706 def layout_commits(self):
1707 positions = self.position_nodes()
1709 # Each edge is accounted in two commits. Hence, accumulate invalid
1710 # edges to prevent double edge invalidation.
1711 invalid_edges = set()
1713 for oid, (x, y) in positions.items():
1714 item = self.items[oid]
1716 pos = item.pos()
1717 if pos != (x, y):
1718 item.setPos(x, y)
1720 for edge in item.edges.values():
1721 invalid_edges.add(edge)
1723 for edge in invalid_edges:
1724 edge.commits_were_invalidated()
1726 # Commit node layout technique
1728 # Nodes are aligned by a mesh. Columns and rows are distributed using
1729 # algorithms described below.
1731 # Row assignment algorithm
1733 # The algorithm aims consequent.
1734 # 1. A commit should be above all its parents.
1735 # 2. No commit should be at right side of a commit with a tag in same row.
1736 # This prevents overlapping of tag labels with commits and other labels.
1737 # 3. Commit density should be maximized.
1739 # The algorithm requires that all parents of a commit were assigned column.
1740 # Nodes must be traversed in generation ascend order. This guarantees that all
1741 # parents of a commit were assigned row. So, the algorithm may operate in
1742 # course of column assignment algorithm.
1744 # Row assignment uses frontier. A frontier is a dictionary that contains
1745 # minimum available row index for each column. It propagates during the
1746 # algorithm. Set of cells with tags is also maintained to meet second aim.
1748 # Initialization is performed by reset_rows method. Each new column should
1749 # be declared using declare_column method. Getting row for a cell is
1750 # implemented in alloc_cell method. Frontier must be propagated for any child
1751 # of fork commit which occupies different column. This meets first aim.
1753 # Column assignment algorithm
1755 # The algorithm traverses nodes in generation ascend order. This guarantees
1756 # that a node will be visited after all its parents.
1758 # The set of occupied columns are maintained during work. Initially it is
1759 # empty and no node occupied a column. Empty columns are allocated on demand.
1760 # Free index for column being allocated is searched in following way.
1761 # 1. Start from desired column and look towards graph center (0 column).
1762 # 2. Start from center and look in both directions simultaneously.
1763 # Desired column is defaulted to 0. Fork node should set desired column for
1764 # children equal to its one. This prevents branch from jumping too far from
1765 # its fork.
1767 # Initialization is performed by reset_columns method. Column allocation is
1768 # implemented in alloc_column method. Initialization and main loop are in
1769 # recompute_grid method. The method also embeds row assignment algorithm by
1770 # implementation.
1772 # Actions for each node are follow.
1773 # 1. If the node was not assigned a column then it is assigned empty one.
1774 # 2. Allocate row.
1775 # 3. Allocate columns for children.
1776 # If a child have a column assigned then it should no be overridden. One of
1777 # children is assigned same column as the node. If the node is a fork then the
1778 # child is chosen in generation descent order. This is a heuristic and it only
1779 # affects resulting appearance of the graph. Other children are assigned empty
1780 # columns in same order. It is the heuristic too.
1781 # 4. If no child occupies column of the node then leave it.
1782 # It is possible in consequent situations.
1783 # 4.1 The node is a leaf.
1784 # 4.2 The node is a fork and all its children are already assigned side
1785 # column. It is possible if all the children are merges.
1786 # 4.3 Single node child is a merge that is already assigned a column.
1787 # 5. Propagate frontier with respect to this node.
1788 # Each frontier entry corresponding to column occupied by any node's child
1789 # must be gather than node row index. This meets first aim of the row
1790 # assignment algorithm.
1791 # Note that frontier of child that occupies same row was propagated during
1792 # step 2. Hence, it must be propagated for children on side columns.
1794 def reset_columns(self):
1795 # Some children of displayed commits might not be accounted in
1796 # 'commits' list. It is common case during loading of big graph.
1797 # But, they are assigned a column that must be reseted. Hence, use
1798 # depth-first traversal to reset all columns assigned.
1799 for node in self.commits:
1800 if node.column is None:
1801 continue
1802 stack = [node]
1803 while stack:
1804 node = stack.pop()
1805 node.column = None
1806 for child in node.children:
1807 if child.column is not None:
1808 stack.append(child)
1810 self.columns = {}
1811 self.max_column = 0
1812 self.min_column = 0
1814 def reset_rows(self):
1815 self.frontier = {}
1816 self.tagged_cells = set()
1818 def declare_column(self, column):
1819 if self.frontier:
1820 # Align new column frontier by frontier of nearest column. If all
1821 # columns were left then select maximum frontier value.
1822 if not self.columns:
1823 self.frontier[column] = max(list(self.frontier.values()))
1824 return
1825 # This is heuristic that mostly affects roots. Note that the
1826 # frontier values for fork children will be overridden in course of
1827 # propagate_frontier.
1828 for offset in itertools.count(1):
1829 for c in [column + offset, column - offset]:
1830 if c not in self.columns:
1831 # Column 'c' is not occupied.
1832 continue
1833 try:
1834 frontier = self.frontier[c]
1835 except KeyError:
1836 # Column 'c' was never allocated.
1837 continue
1839 frontier -= 1
1840 # The frontier of the column may be higher because of
1841 # tag overlapping prevention performed for previous head.
1842 try:
1843 if self.frontier[column] >= frontier:
1844 break
1845 except KeyError:
1846 pass
1848 self.frontier[column] = frontier
1849 break
1850 else:
1851 continue
1852 break
1853 else:
1854 # First commit must be assigned 0 row.
1855 self.frontier[column] = 0
1857 def alloc_column(self, column=0):
1858 columns = self.columns
1859 # First, look for free column by moving from desired column to graph
1860 # center (column 0).
1861 for c in range(column, 0, -1 if column > 0 else 1):
1862 if c not in columns:
1863 if c > self.max_column:
1864 self.max_column = c
1865 elif c < self.min_column:
1866 self.min_column = c
1867 break
1868 else:
1869 # If no free column was found between graph center and desired
1870 # column then look for free one by moving from center along both
1871 # directions simultaneously.
1872 for c in itertools.count(0):
1873 if c not in columns:
1874 if c > self.max_column:
1875 self.max_column = c
1876 break
1877 c = -c
1878 if c not in columns:
1879 if c < self.min_column:
1880 self.min_column = c
1881 break
1882 self.declare_column(c)
1883 columns[c] = 1
1884 return c
1886 def alloc_cell(self, column, tags):
1887 # Get empty cell from frontier.
1888 cell_row = self.frontier[column]
1890 if tags:
1891 # Prevent overlapping of tag with cells already allocated a row.
1892 if self.x_off > 0:
1893 can_overlap = list(range(column + 1, self.max_column + 1))
1894 else:
1895 can_overlap = list(range(column - 1, self.min_column - 1, -1))
1896 for c in can_overlap:
1897 frontier = self.frontier[c]
1898 if frontier > cell_row:
1899 cell_row = frontier
1901 # Avoid overlapping with tags of commits at cell_row.
1902 if self.x_off > 0:
1903 can_overlap = list(range(self.min_column, column))
1904 else:
1905 can_overlap = list(range(self.max_column, column, -1))
1906 for cell_row in itertools.count(cell_row):
1907 for c in can_overlap:
1908 if (c, cell_row) in self.tagged_cells:
1909 # Overlapping. Try next row.
1910 break
1911 else:
1912 # No overlapping was found.
1913 break
1914 # Note that all checks should be made for new cell_row value.
1916 if tags:
1917 self.tagged_cells.add((column, cell_row))
1919 # Propagate frontier.
1920 self.frontier[column] = cell_row + 1
1921 return cell_row
1923 def propagate_frontier(self, column, value):
1924 current = self.frontier[column]
1925 if current < value:
1926 self.frontier[column] = value
1928 def leave_column(self, column):
1929 count = self.columns[column]
1930 if count == 1:
1931 del self.columns[column]
1932 else:
1933 self.columns[column] = count - 1
1935 def recompute_grid(self):
1936 self.reset_columns()
1937 self.reset_rows()
1939 for node in sort_by_generation(list(self.commits)):
1940 if node.column is None:
1941 # Node is either root or its parent is not in items. The last
1942 # happens when tree loading is in progress. Allocate new
1943 # columns for such nodes.
1944 node.column = self.alloc_column()
1946 node.row = self.alloc_cell(node.column, node.tags)
1948 # Allocate columns for children which are still without one. Also
1949 # propagate frontier for children.
1950 if node.is_fork():
1951 sorted_children = sorted(node.children,
1952 key=lambda c: c.generation,
1953 reverse=True)
1954 citer = iter(sorted_children)
1955 for child in citer:
1956 if child.column is None:
1957 # Top most child occupies column of parent.
1958 child.column = node.column
1959 # Note that frontier is propagated in course of
1960 # alloc_cell.
1961 break
1962 else:
1963 self.propagate_frontier(child.column, node.row + 1)
1964 else:
1965 # No child occupies same column.
1966 self.leave_column(node.column)
1967 # Note that the loop below will pass no iteration.
1969 # Rest children are allocated new column.
1970 for child in citer:
1971 if child.column is None:
1972 child.column = self.alloc_column(node.column)
1973 self.propagate_frontier(child.column, node.row + 1)
1974 elif node.children:
1975 child = node.children[0]
1976 if child.column is None:
1977 child.column = node.column
1978 # Note that frontier is propagated in course of alloc_cell.
1979 elif child.column != node.column:
1980 # Child node have other parents and occupies column of one
1981 # of them.
1982 self.leave_column(node.column)
1983 # But frontier must be propagated with respect to this
1984 # parent.
1985 self.propagate_frontier(child.column, node.row + 1)
1986 else:
1987 # This is a leaf node.
1988 self.leave_column(node.column)
1990 def position_nodes(self):
1991 self.recompute_grid()
1993 x_start = self.x_start
1994 x_min = self.x_min
1995 x_off = self.x_off
1996 y_off = self.y_off
1998 positions = {}
2000 for node in self.commits:
2001 x_pos = x_start + node.column * x_off
2002 y_pos = y_off + node.row * y_off
2004 positions[node.oid] = (x_pos, y_pos)
2005 x_min = min(x_min, x_pos)
2007 self.x_min = x_min
2009 return positions
2011 # Qt overrides
2012 def contextMenuEvent(self, event):
2013 self.context_menu_event(event)
2015 def mousePressEvent(self, event):
2016 if event.button() == Qt.MidButton:
2017 pos = event.pos()
2018 self.mouse_start = [pos.x(), pos.y()]
2019 self.saved_matrix = self.transform()
2020 self.is_panning = True
2021 return
2022 if event.button() == Qt.RightButton:
2023 event.ignore()
2024 return
2025 if event.button() == Qt.LeftButton:
2026 self.pressed = True
2027 self.handle_event(QtWidgets.QGraphicsView.mousePressEvent, event)
2029 def mouseMoveEvent(self, event):
2030 pos = self.mapToScene(event.pos())
2031 if self.is_panning:
2032 self.pan(event)
2033 return
2034 self.last_mouse[0] = pos.x()
2035 self.last_mouse[1] = pos.y()
2036 self.handle_event(QtWidgets.QGraphicsView.mouseMoveEvent, event)
2037 if self.pressed:
2038 self.viewport().repaint()
2040 def mouseReleaseEvent(self, event):
2041 self.pressed = False
2042 if event.button() == Qt.MidButton:
2043 self.is_panning = False
2044 return
2045 self.handle_event(QtWidgets.QGraphicsView.mouseReleaseEvent, event)
2046 self.selection_list = []
2047 self.viewport().repaint()
2049 def wheelEvent(self, event):
2050 """Handle Qt mouse wheel events."""
2051 if event.modifiers() & Qt.ControlModifier:
2052 self.wheel_zoom(event)
2053 else:
2054 self.wheel_pan(event)
2056 def fitInView(self, rect, flags=Qt.IgnoreAspectRatio):
2057 """Override fitInView to remove unwanted margins
2059 https://bugreports.qt.io/browse/QTBUG-42331 - based on QT sources
2062 if self.scene() is None or rect.isNull():
2063 return
2064 unity = self.transform().mapRect(QtCore.QRectF(0, 0, 1, 1))
2065 self.scale(1.0/unity.width(), 1.0/unity.height())
2066 view_rect = self.viewport().rect()
2067 scene_rect = self.transform().mapRect(rect)
2068 xratio = view_rect.width() / scene_rect.width()
2069 yratio = view_rect.height() / scene_rect.height()
2070 if flags == Qt.KeepAspectRatio:
2071 xratio = yratio = min(xratio, yratio)
2072 elif flags == Qt.KeepAspectRatioByExpanding:
2073 xratio = yratio = max(xratio, yratio)
2074 self.scale(xratio, yratio)
2075 self.centerOn(rect.center())
2078 def sort_by_generation(commits):
2079 if len(commits) < 2:
2080 return commits
2081 commits.sort(key=lambda x: x.generation)
2082 return commits
2085 # Glossary
2086 # ========
2087 # oid -- Git objects IDs (i.e. SHA-1 IDs)
2088 # ref -- Git references that resolve to a commit-ish (HEAD, branches, tags)