dag: fix pylint warnings
[git-cola.git] / cola / widgets / dag.py
blobc46b2e29b8f85c15e37e1f343a1012255593999d
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 # pylint: disable=too-many-ancestors
335 class CommitTreeWidget(standard.TreeWidget, ViewerMixin):
337 diff_commits = Signal(object, object)
338 zoom_to_fit = Signal()
340 def __init__(self, context, notifier, parent):
341 standard.TreeWidget.__init__(self, parent)
342 ViewerMixin.__init__(self)
344 self.setSelectionMode(self.ExtendedSelection)
345 self.setHeaderLabels([N_('Summary'), N_('Author'), N_('Date, Time')])
347 self.context = context
348 self.oidmap = {}
349 self.menu_actions = None
350 self.notifier = notifier
351 self.selecting = False
352 self.commits = []
353 self._adjust_columns = False
355 self.action_up = qtutils.add_action(
356 self, N_('Go Up'), self.go_up, hotkeys.MOVE_UP)
358 self.action_down = qtutils.add_action(
359 self, N_('Go Down'), self.go_down, hotkeys.MOVE_DOWN)
361 self.zoom_to_fit_action = qtutils.add_action(
362 self, N_('Zoom to Fit'), self.zoom_to_fit.emit, hotkeys.FIT)
364 notifier.add_observer(diff.COMMITS_SELECTED, self.commits_selected)
365 # pylint: disable=no-member
366 self.itemSelectionChanged.connect(self.selection_changed)
368 def export_state(self):
369 """Export the widget's state"""
370 # The base class method is intentionally overridden because we only
371 # care about the details below for this subwidget.
372 state = {}
373 state['column_widths'] = self.column_widths()
374 return state
376 def apply_state(self, state):
377 """Apply the exported widget state"""
378 try:
379 column_widths = state['column_widths']
380 except (KeyError, ValueError):
381 column_widths = None
382 if column_widths:
383 self.set_column_widths(column_widths)
384 else:
385 # Defer showing the columns until we are shown, and our true width
386 # is known. Calling adjust_columns() here ends up with the wrong
387 # answer because we have not yet been parented to the layout.
388 # We set this flag that we process once during our initial
389 # showEvent().
390 self._adjust_columns = True
391 return True
393 # Qt overrides
394 def showEvent(self, event):
395 """Override QWidget::showEvent() to size columns when we are shown"""
396 if self._adjust_columns:
397 self._adjust_columns = False
398 width = self.width()
399 two_thirds = (width * 2) // 3
400 one_sixth = width // 6
402 self.setColumnWidth(0, two_thirds)
403 self.setColumnWidth(1, one_sixth)
404 self.setColumnWidth(2, one_sixth)
405 return standard.TreeWidget.showEvent(self, event)
407 # ViewerMixin
408 def go_up(self):
409 self.goto(self.itemAbove)
411 def go_down(self):
412 self.goto(self.itemBelow)
414 def goto(self, finder):
415 items = self.selected_items()
416 item = items[0] if items else None
417 if item is None:
418 return
419 found = finder(item)
420 if found:
421 self.select([found.commit.oid])
423 def selected_commit_range(self):
424 selected_items = self.selected_items()
425 if not selected_items:
426 return None, None
427 return selected_items[-1].commit.oid, selected_items[0].commit.oid
429 def set_selecting(self, selecting):
430 self.selecting = selecting
432 def selection_changed(self):
433 items = self.selected_items()
434 if not items:
435 return
436 self.set_selecting(True)
437 self.notifier.notify_observers(diff.COMMITS_SELECTED,
438 [i.commit for i in items])
439 self.set_selecting(False)
441 def commits_selected(self, commits):
442 if self.selecting:
443 return
444 with qtutils.BlockSignals(self):
445 self.select([commit.oid for commit in commits])
447 def select(self, oids):
448 if not oids:
449 return
450 self.clearSelection()
451 for oid in oids:
452 try:
453 item = self.oidmap[oid]
454 except KeyError:
455 continue
456 self.scrollToItem(item)
457 item.setSelected(True)
459 def clear(self):
460 QtWidgets.QTreeWidget.clear(self)
461 self.oidmap.clear()
462 self.commits = []
464 def add_commits(self, commits):
465 self.commits.extend(commits)
466 items = []
467 for c in reversed(commits):
468 item = CommitTreeWidgetItem(c)
469 items.append(item)
470 self.oidmap[c.oid] = item
471 for tag in c.tags:
472 self.oidmap[tag] = item
473 self.insertTopLevelItems(0, items)
475 def create_patch(self):
476 items = self.selectedItems()
477 if not items:
478 return
479 context = self.context
480 oids = [item.commit.oid for item in reversed(items)]
481 all_oids = [c.oid for c in self.commits]
482 cmds.do(cmds.FormatPatch, context, oids, all_oids)
484 # Qt overrides
485 def contextMenuEvent(self, event):
486 self.context_menu_event(event)
488 def mousePressEvent(self, event):
489 if event.button() == Qt.RightButton:
490 event.accept()
491 return
492 QtWidgets.QTreeWidget.mousePressEvent(self, event)
495 class GitDAG(standard.MainWindow):
496 """The git-dag widget."""
497 updated = Signal()
499 def __init__(self, context, params, parent=None, settings=None):
500 super(GitDAG, self).__init__(parent)
502 self.setMinimumSize(420, 420)
504 # change when widgets are added/removed
505 self.widget_version = 2
506 self.context = context
507 self.params = params
508 self.model = context.model
509 self.settings = settings
511 self.commits = {}
512 self.commit_list = []
513 self.selection = []
514 self.old_refs = set()
515 self.old_oids = None
516 self.old_count = 0
517 self.force_refresh = False
519 self.thread = None
520 self.revtext = completion.GitLogLineEdit(context)
521 self.maxresults = standard.SpinBox()
523 self.zoom_out = qtutils.create_action_button(
524 tooltip=N_('Zoom Out'), icon=icons.zoom_out())
526 self.zoom_in = qtutils.create_action_button(
527 tooltip=N_('Zoom In'), icon=icons.zoom_in())
529 self.zoom_to_fit = qtutils.create_action_button(
530 tooltip=N_('Zoom to Fit'), icon=icons.zoom_fit_best())
532 self.notifier = notifier = observable.Observable()
533 self.notifier.refs_updated = refs_updated = 'refs_updated'
534 self.notifier.add_observer(refs_updated, self.display)
535 self.notifier.add_observer(filelist.HISTORIES_SELECTED,
536 self.histories_selected)
537 self.notifier.add_observer(filelist.DIFFTOOL_SELECTED,
538 self.difftool_selected)
539 self.notifier.add_observer(diff.COMMITS_SELECTED, self.commits_selected)
541 self.treewidget = CommitTreeWidget(context, notifier, self)
542 self.diffwidget = diff.DiffWidget(context, notifier, self,
543 is_commit=True)
544 self.filewidget = filelist.FileWidget(context, notifier, self)
545 self.graphview = GraphView(context, notifier, self)
547 self.proxy = FocusRedirectProxy(self.treewidget,
548 self.graphview,
549 self.filewidget)
551 self.viewer_actions = actions = viewer_actions(self)
552 self.treewidget.menu_actions = actions
553 self.graphview.menu_actions = actions
555 self.controls_layout = qtutils.hbox(defs.no_margin, defs.spacing,
556 self.revtext, self.maxresults)
558 self.controls_widget = QtWidgets.QWidget()
559 self.controls_widget.setLayout(self.controls_layout)
561 self.log_dock = qtutils.create_dock(N_('Log'), self, stretch=False)
562 self.log_dock.setWidget(self.treewidget)
563 log_dock_titlebar = self.log_dock.titleBarWidget()
564 log_dock_titlebar.add_corner_widget(self.controls_widget)
566 self.file_dock = qtutils.create_dock(N_('Files'), self)
567 self.file_dock.setWidget(self.filewidget)
569 self.diff_dock = qtutils.create_dock(N_('Diff'), self)
570 self.diff_dock.setWidget(self.diffwidget)
572 self.graph_controls_layout = qtutils.hbox(
573 defs.no_margin, defs.button_spacing,
574 self.zoom_out, self.zoom_in, self.zoom_to_fit, defs.spacing)
576 self.graph_controls_widget = QtWidgets.QWidget()
577 self.graph_controls_widget.setLayout(self.graph_controls_layout)
579 self.graphview_dock = qtutils.create_dock(N_('Graph'), self)
580 self.graphview_dock.setWidget(self.graphview)
581 graph_titlebar = self.graphview_dock.titleBarWidget()
582 graph_titlebar.add_corner_widget(self.graph_controls_widget)
584 self.lock_layout_action = qtutils.add_action_bool(
585 self, N_('Lock Layout'), self.set_lock_layout, False)
587 self.refresh_action = qtutils.add_action(
588 self, N_('Refresh'), self.refresh, hotkeys.REFRESH)
590 # Create the application menu
591 self.menubar = QtWidgets.QMenuBar(self)
592 self.setMenuBar(self.menubar)
594 # View Menu
595 self.view_menu = qtutils.add_menu(N_('View'), self.menubar)
596 self.view_menu.addAction(self.refresh_action)
597 self.view_menu.addAction(self.log_dock.toggleViewAction())
598 self.view_menu.addAction(self.graphview_dock.toggleViewAction())
599 self.view_menu.addAction(self.diff_dock.toggleViewAction())
600 self.view_menu.addAction(self.file_dock.toggleViewAction())
601 self.view_menu.addSeparator()
602 self.view_menu.addAction(self.lock_layout_action)
604 left = Qt.LeftDockWidgetArea
605 right = Qt.RightDockWidgetArea
606 self.addDockWidget(left, self.log_dock)
607 self.addDockWidget(left, self.diff_dock)
608 self.addDockWidget(right, self.graphview_dock)
609 self.addDockWidget(right, self.file_dock)
611 # Also re-loads dag.* from the saved state
612 self.init_state(settings, self.resize_to_desktop)
614 qtutils.connect_button(self.zoom_out, self.graphview.zoom_out)
615 qtutils.connect_button(self.zoom_in, self.graphview.zoom_in)
616 qtutils.connect_button(self.zoom_to_fit,
617 self.graphview.zoom_to_fit)
619 self.treewidget.zoom_to_fit.connect(self.graphview.zoom_to_fit)
620 self.treewidget.diff_commits.connect(self.diff_commits)
621 self.graphview.diff_commits.connect(self.diff_commits)
622 self.filewidget.grab_file.connect(self.grab_file)
624 # pylint: disable=no-member
625 self.maxresults.editingFinished.connect(self.display)
627 self.revtext.textChanged.connect(self.text_changed)
628 self.revtext.activated.connect(self.display)
629 self.revtext.enter.connect(self.display)
630 self.revtext.down.connect(self.focus_tree)
632 # The model is updated in another thread so use
633 # signals/slots to bring control back to the main GUI thread
634 self.model.add_observer(self.model.message_updated, self.updated.emit)
635 self.updated.connect(self.model_updated, type=Qt.QueuedConnection)
637 qtutils.add_action(self, 'Focus', self.focus_input, hotkeys.FOCUS)
638 qtutils.add_close_action(self)
640 self.set_params(params)
642 def set_params(self, params):
643 context = self.context
644 self.params = params
646 # Update fields affected by model
647 self.revtext.setText(params.ref)
648 self.maxresults.setValue(params.count)
649 self.update_window_title()
651 if self.thread is not None:
652 self.thread.stop()
654 self.thread = ReaderThread(context, params, self)
656 thread = self.thread
657 thread.begin.connect(self.thread_begin, type=Qt.QueuedConnection)
658 thread.status.connect(self.thread_status, type=Qt.QueuedConnection)
659 thread.add.connect(self.add_commits, type=Qt.QueuedConnection)
660 thread.end.connect(self.thread_end, type=Qt.QueuedConnection)
662 def focus_input(self):
663 self.revtext.setFocus()
665 def focus_tree(self):
666 self.treewidget.setFocus()
668 def text_changed(self, txt):
669 self.params.ref = txt
670 self.update_window_title()
672 def update_window_title(self):
673 project = self.model.project
674 if self.params.ref:
675 self.setWindowTitle(N_('%(project)s: %(ref)s - DAG')
676 % dict(project=project, ref=self.params.ref))
677 else:
678 self.setWindowTitle(project + N_(' - DAG'))
680 def export_state(self):
681 state = standard.MainWindow.export_state(self)
682 state['count'] = self.params.count
683 state['log'] = self.treewidget.export_state()
684 return state
686 def apply_state(self, state):
687 result = standard.MainWindow.apply_state(self, state)
688 try:
689 count = state['count']
690 if self.params.overridden('count'):
691 count = self.params.count
692 except (KeyError, TypeError, ValueError, AttributeError):
693 count = self.params.count
694 result = False
695 self.params.set_count(count)
696 self.lock_layout_action.setChecked(state.get('lock_layout', False))
698 try:
699 log_state = state['log']
700 except (KeyError, ValueError):
701 log_state = None
702 if log_state:
703 self.treewidget.apply_state(log_state)
705 return result
707 def model_updated(self):
708 self.display()
709 self.update_window_title()
711 def refresh(self):
712 """Unconditionally refresh the DAG"""
713 # self.force_refresh triggers an Unconditional redraw
714 self.force_refresh = True
715 cmds.do(cmds.Refresh, self.context)
716 self.force_refresh = False
718 def display(self):
719 """Update the view when the Git refs change"""
720 ref = get(self.revtext)
721 count = get(self.maxresults)
722 context = self.context
723 model = self.model
724 # The DAG tries to avoid updating when the object IDs have not
725 # changed. Without doing this the DAG constantly redraws itself
726 # whenever inotify sends update events, which hurts usability.
728 # To minimize redraws we leverage `git rev-parse`. The strategy is to
729 # use `git rev-parse` on the input line, which converts each argument
730 # into object IDs. From there it's a simple matter of detecting when
731 # the object IDs changed.
733 # In addition to object IDs, we also need to know when the set of
734 # named references (branches, tags) changes so that an update is
735 # triggered when new branches and tags are created.
736 refs = set(model.local_branches + model.remote_branches + model.tags)
737 argv = utils.shell_split(ref or 'HEAD')
738 oids = gitcmds.parse_refs(context, argv)
739 update = (self.force_refresh
740 or count != self.old_count
741 or oids != self.old_oids
742 or refs != self.old_refs)
743 if update:
744 self.thread.stop()
745 self.params.set_ref(ref)
746 self.params.set_count(count)
747 self.thread.start()
749 self.old_oids = oids
750 self.old_count = count
751 self.old_refs = refs
753 def commits_selected(self, commits):
754 if commits:
755 self.selection = commits
757 def clear(self):
758 self.commits.clear()
759 self.commit_list = []
760 self.graphview.clear()
761 self.treewidget.clear()
763 def add_commits(self, commits):
764 self.commit_list.extend(commits)
765 # Keep track of commits
766 for commit_obj in commits:
767 self.commits[commit_obj.oid] = commit_obj
768 for tag in commit_obj.tags:
769 self.commits[tag] = commit_obj
770 self.graphview.add_commits(commits)
771 self.treewidget.add_commits(commits)
773 def thread_begin(self):
774 self.clear()
776 def thread_end(self):
777 self.restore_selection()
779 def thread_status(self, successful):
780 self.revtext.hint.set_error(not successful)
782 def restore_selection(self):
783 selection = self.selection
784 try:
785 commit_obj = self.commit_list[-1]
786 except IndexError:
787 # No commits, exist, early-out
788 return
790 new_commits = [self.commits.get(s.oid, None) for s in selection]
791 new_commits = [c for c in new_commits if c is not None]
792 if new_commits:
793 # The old selection exists in the new state
794 self.notifier.notify_observers(diff.COMMITS_SELECTED, new_commits)
795 else:
796 # The old selection is now empty. Select the top-most commit
797 self.notifier.notify_observers(diff.COMMITS_SELECTED, [commit_obj])
799 self.graphview.set_initial_view()
801 def diff_commits(self, a, b):
802 paths = self.params.paths()
803 if paths:
804 cmds.difftool_launch(self.context, left=a, right=b, paths=paths)
805 else:
806 difftool.diff_commits(self.context, self, a, b)
808 # Qt overrides
809 def closeEvent(self, event):
810 self.revtext.close_popup()
811 self.thread.stop()
812 standard.MainWindow.closeEvent(self, event)
814 def histories_selected(self, histories):
815 argv = [self.model.currentbranch, '--']
816 argv.extend(histories)
817 text = core.list2cmdline(argv)
818 self.revtext.setText(text)
819 self.display()
821 def difftool_selected(self, files):
822 bottom, top = self.treewidget.selected_commit_range()
823 if not top:
824 return
825 cmds.difftool_launch(self.context, left=bottom, left_take_parent=True,
826 right=top, paths=files)
828 def grab_file(self, filename):
829 """Save the selected file from the filelist widget"""
830 oid = self.treewidget.selected_oid()
831 model = browse.BrowseModel(oid, filename=filename)
832 browse.save_path(self.context, filename, model)
835 class ReaderThread(QtCore.QThread):
836 begin = Signal()
837 add = Signal(object)
838 end = Signal()
839 status = Signal(object)
841 def __init__(self, context, params, parent):
842 QtCore.QThread.__init__(self, parent)
843 self.context = context
844 self.params = params
845 self._abort = False
846 self._stop = False
847 self._mutex = QtCore.QMutex()
848 self._condition = QtCore.QWaitCondition()
850 def run(self):
851 context = self.context
852 repo = dag.RepoReader(context, self.params)
853 repo.reset()
854 self.begin.emit()
855 commits = []
856 for c in repo.get():
857 self._mutex.lock()
858 if self._stop:
859 self._condition.wait(self._mutex)
860 self._mutex.unlock()
861 if self._abort:
862 repo.reset()
863 return
864 commits.append(c)
865 if len(commits) >= 512:
866 self.add.emit(commits)
867 commits = []
869 self.status.emit(repo.returncode == 0)
870 if commits:
871 self.add.emit(commits)
872 self.end.emit()
874 def start(self):
875 self._abort = False
876 self._stop = False
877 QtCore.QThread.start(self)
879 def pause(self):
880 self._mutex.lock()
881 self._stop = True
882 self._mutex.unlock()
884 def resume(self):
885 self._mutex.lock()
886 self._stop = False
887 self._mutex.unlock()
888 self._condition.wakeOne()
890 def stop(self):
891 self._abort = True
892 self.wait()
895 class Cache(object):
897 _label_font = None
899 @classmethod
900 def label_font(cls):
901 font = cls._label_font
902 if font is None:
903 font = cls._label_font = QtWidgets.QApplication.font()
904 font.setPointSize(6)
905 return font
908 class Edge(QtWidgets.QGraphicsItem):
909 item_type = QtWidgets.QGraphicsItem.UserType + 1
911 def __init__(self, source, dest):
913 QtWidgets.QGraphicsItem.__init__(self)
915 self.setAcceptedMouseButtons(Qt.NoButton)
916 self.source = source
917 self.dest = dest
918 self.commit = source.commit
919 self.setZValue(-2)
921 self.recompute_bound()
922 self.path = None
923 self.path_valid = False
925 # Choose a new color for new branch edges
926 if self.source.x() < self.dest.x():
927 color = EdgeColor.cycle()
928 line = Qt.SolidLine
929 elif self.source.x() != self.dest.x():
930 color = EdgeColor.current()
931 line = Qt.SolidLine
932 else:
933 color = EdgeColor.current()
934 line = Qt.SolidLine
936 self.pen = QtGui.QPen(color, 4.0, line, Qt.SquareCap, Qt.RoundJoin)
938 def recompute_bound(self):
939 dest_pt = Commit.item_bbox.center()
941 self.source_pt = self.mapFromItem(self.source, dest_pt)
942 self.dest_pt = self.mapFromItem(self.dest, dest_pt)
943 self.line = QtCore.QLineF(self.source_pt, self.dest_pt)
945 width = self.dest_pt.x() - self.source_pt.x()
946 height = self.dest_pt.y() - self.source_pt.y()
947 rect = QtCore.QRectF(self.source_pt, QtCore.QSizeF(width, height))
948 self.bound = rect.normalized()
950 def commits_were_invalidated(self):
951 self.recompute_bound()
952 self.prepareGeometryChange()
953 # The path should not be recomputed immediately because just small part
954 # of DAG is actually shown at same time. It will be recomputed on
955 # demand in course of 'paint' method.
956 self.path_valid = False
957 # Hence, just queue redrawing.
958 self.update()
960 # Qt overrides
961 def type(self):
962 return self.item_type
964 def boundingRect(self):
965 return self.bound
967 def recompute_path(self):
968 QRectF = QtCore.QRectF
969 QPointF = QtCore.QPointF
971 arc_rect = 10
972 connector_length = 5
974 path = QtGui.QPainterPath()
976 if self.source.x() == self.dest.x():
977 path.moveTo(self.source.x(), self.source.y())
978 path.lineTo(self.dest.x(), self.dest.y())
979 else:
980 # Define points starting from source
981 point1 = QPointF(self.source.x(), self.source.y())
982 point2 = QPointF(point1.x(), point1.y() - connector_length)
983 point3 = QPointF(point2.x() + arc_rect, point2.y() - arc_rect)
985 # Define points starting from dest
986 point4 = QPointF(self.dest.x(), self.dest.y())
987 point5 = QPointF(point4.x(), point3.y() - arc_rect)
988 point6 = QPointF(point5.x() - arc_rect, point5.y() + arc_rect)
990 start_angle_arc1 = 180
991 span_angle_arc1 = 90
992 start_angle_arc2 = 90
993 span_angle_arc2 = -90
995 # If the dest is at the left of the source, then we
996 # need to reverse some values
997 if self.source.x() > self.dest.x():
998 point3 = QPointF(point2.x() - arc_rect, point3.y())
999 point6 = QPointF(point5.x() + arc_rect, point6.y())
1001 span_angle_arc1 = 90
1003 path.moveTo(point1)
1004 path.lineTo(point2)
1005 path.arcTo(QRectF(point2, point3),
1006 start_angle_arc1, span_angle_arc1)
1007 path.lineTo(point6)
1008 path.arcTo(QRectF(point6, point5),
1009 start_angle_arc2, span_angle_arc2)
1010 path.lineTo(point4)
1012 self.path = path
1013 self.path_valid = True
1015 def paint(self, painter, _option, _widget):
1016 if not self.path_valid:
1017 self.recompute_path()
1018 painter.setPen(self.pen)
1019 painter.drawPath(self.path)
1022 class EdgeColor(object):
1023 """An edge color factory"""
1025 current_color_index = 0
1026 colors = [
1027 QtGui.QColor(Qt.red),
1028 QtGui.QColor(Qt.green),
1029 QtGui.QColor(Qt.blue),
1030 QtGui.QColor(Qt.black),
1031 QtGui.QColor(Qt.darkRed),
1032 QtGui.QColor(Qt.darkGreen),
1033 QtGui.QColor(Qt.darkBlue),
1034 QtGui.QColor(Qt.cyan),
1035 QtGui.QColor(Qt.magenta),
1036 # Orange; Qt.yellow is too low-contrast
1037 qtutils.rgba(0xff, 0x66, 0x00),
1038 QtGui.QColor(Qt.gray),
1039 QtGui.QColor(Qt.darkCyan),
1040 QtGui.QColor(Qt.darkMagenta),
1041 QtGui.QColor(Qt.darkYellow),
1042 QtGui.QColor(Qt.darkGray),
1045 @classmethod
1046 def cycle(cls):
1047 cls.current_color_index += 1
1048 cls.current_color_index %= len(cls.colors)
1049 color = cls.colors[cls.current_color_index]
1050 color.setAlpha(128)
1051 return color
1053 @classmethod
1054 def current(cls):
1055 return cls.colors[cls.current_color_index]
1057 @classmethod
1058 def reset(cls):
1059 cls.current_color_index = 0
1062 class Commit(QtWidgets.QGraphicsItem):
1063 item_type = QtWidgets.QGraphicsItem.UserType + 2
1064 commit_radius = 12.0
1065 merge_radius = 18.0
1067 item_shape = QtGui.QPainterPath()
1068 item_shape.addRect(commit_radius/-2.0,
1069 commit_radius/-2.0,
1070 commit_radius, commit_radius)
1071 item_bbox = item_shape.boundingRect()
1073 inner_rect = QtGui.QPainterPath()
1074 inner_rect.addRect(commit_radius/-2.0 + 2.0,
1075 commit_radius/-2.0 + 2.0,
1076 commit_radius - 4.0,
1077 commit_radius - 4.0)
1078 inner_rect = inner_rect.boundingRect()
1080 commit_color = QtGui.QColor(Qt.white)
1081 outline_color = commit_color.darker()
1082 merge_color = QtGui.QColor(Qt.lightGray)
1084 commit_selected_color = QtGui.QColor(Qt.green)
1085 selected_outline_color = commit_selected_color.darker()
1087 commit_pen = QtGui.QPen()
1088 commit_pen.setWidth(1.0)
1089 commit_pen.setColor(outline_color)
1091 def __init__(self, commit,
1092 notifier,
1093 selectable=QtWidgets.QGraphicsItem.ItemIsSelectable,
1094 cursor=Qt.PointingHandCursor,
1095 xpos=commit_radius/2.0 + 1.0,
1096 cached_commit_color=commit_color,
1097 cached_merge_color=merge_color):
1099 QtWidgets.QGraphicsItem.__init__(self)
1101 self.commit = commit
1102 self.notifier = notifier
1103 self.selected = False
1105 self.setZValue(0)
1106 self.setFlag(selectable)
1107 self.setCursor(cursor)
1108 self.setToolTip(commit.oid[:12] + ': ' + commit.summary)
1110 if commit.tags:
1111 self.label = label = Label(commit)
1112 label.setParentItem(self)
1113 label.setPos(xpos + 1, -self.commit_radius/2.0)
1114 else:
1115 self.label = None
1117 if len(commit.parents) > 1:
1118 self.brush = cached_merge_color
1119 else:
1120 self.brush = cached_commit_color
1122 self.pressed = False
1123 self.dragged = False
1125 self.edges = {}
1127 def blockSignals(self, blocked):
1128 self.notifier.notification_enabled = not blocked
1130 def itemChange(self, change, value):
1131 if change == QtWidgets.QGraphicsItem.ItemSelectedHasChanged:
1132 # Broadcast selection to other widgets
1133 selected_items = self.scene().selectedItems()
1134 commits = [item.commit for item in selected_items]
1135 self.scene().parent().set_selecting(True)
1136 self.notifier.notify_observers(diff.COMMITS_SELECTED, commits)
1137 self.scene().parent().set_selecting(False)
1139 # Cache the pen for use in paint()
1140 if value:
1141 self.brush = self.commit_selected_color
1142 color = self.selected_outline_color
1143 else:
1144 if len(self.commit.parents) > 1:
1145 self.brush = self.merge_color
1146 else:
1147 self.brush = self.commit_color
1148 color = self.outline_color
1149 commit_pen = QtGui.QPen()
1150 commit_pen.setWidth(1.0)
1151 commit_pen.setColor(color)
1152 self.commit_pen = commit_pen
1154 return QtWidgets.QGraphicsItem.itemChange(self, change, value)
1156 def type(self):
1157 return self.item_type
1159 def boundingRect(self):
1160 return self.item_bbox
1162 def shape(self):
1163 return self.item_shape
1165 def paint(self, painter, option, _widget):
1167 # Do not draw outside the exposed rect
1168 painter.setClipRect(option.exposedRect)
1170 # Draw ellipse
1171 painter.setPen(self.commit_pen)
1172 painter.setBrush(self.brush)
1173 painter.drawEllipse(self.inner_rect)
1175 def mousePressEvent(self, event):
1176 QtWidgets.QGraphicsItem.mousePressEvent(self, event)
1177 self.pressed = True
1178 self.selected = self.isSelected()
1180 def mouseMoveEvent(self, event):
1181 if self.pressed:
1182 self.dragged = True
1183 QtWidgets.QGraphicsItem.mouseMoveEvent(self, event)
1185 def mouseReleaseEvent(self, event):
1186 QtWidgets.QGraphicsItem.mouseReleaseEvent(self, event)
1187 if (not self.dragged and
1188 self.selected and
1189 event.button() == Qt.LeftButton):
1190 return
1191 self.pressed = False
1192 self.dragged = False
1195 class Label(QtWidgets.QGraphicsItem):
1197 item_type = QtWidgets.QGraphicsItem.UserType + 3
1199 head_color = QtGui.QColor(Qt.green)
1200 other_color = QtGui.QColor(Qt.white)
1201 remote_color = QtGui.QColor(Qt.yellow)
1203 head_pen = QtGui.QPen()
1204 head_pen.setColor(head_color.darker().darker())
1205 head_pen.setWidth(1.0)
1207 text_pen = QtGui.QPen()
1208 text_pen.setColor(QtGui.QColor(Qt.darkGray))
1209 text_pen.setWidth(1.0)
1211 alpha = 180
1212 head_color.setAlpha(alpha)
1213 other_color.setAlpha(alpha)
1214 remote_color.setAlpha(alpha)
1216 border = 2
1217 item_spacing = 5
1218 text_offset = 1
1220 def __init__(self, commit):
1221 QtWidgets.QGraphicsItem.__init__(self)
1222 self.setZValue(-1)
1223 self.commit = commit
1225 def type(self):
1226 return self.item_type
1228 def boundingRect(self, cache=Cache):
1229 QPainterPath = QtGui.QPainterPath
1230 QRectF = QtCore.QRectF
1232 width = 72
1233 height = 18
1234 current_width = 0
1235 spacing = self.item_spacing
1236 border = self.border + self.text_offset # text offset=1 in paint()
1238 font = cache.label_font()
1239 item_shape = QPainterPath()
1241 base_rect = QRectF(0, 0, width, height)
1242 base_rect = base_rect.adjusted(-border, -border, border, border)
1243 item_shape.addRect(base_rect)
1245 for tag in self.commit.tags:
1246 text_shape = QPainterPath()
1247 text_shape.addText(current_width, 0, font, tag)
1248 text_rect = text_shape.boundingRect()
1249 box_rect = text_rect.adjusted(-border, -border, border, border)
1250 item_shape.addRect(box_rect)
1251 current_width = item_shape.boundingRect().width() + spacing
1253 return item_shape.boundingRect()
1255 def paint(self, painter, _option, _widget, cache=Cache):
1256 # Draw tags and branches
1257 font = cache.label_font()
1258 painter.setFont(font)
1260 current_width = 0
1261 border = self.border
1262 offset = self.text_offset
1263 spacing = self.item_spacing
1264 QRectF = QtCore.QRectF
1266 HEAD = 'HEAD'
1267 remotes_prefix = 'remotes/'
1268 tags_prefix = 'tags/'
1269 heads_prefix = 'heads/'
1270 remotes_len = len(remotes_prefix)
1271 tags_len = len(tags_prefix)
1272 heads_len = len(heads_prefix)
1274 for tag in self.commit.tags:
1275 if tag == HEAD:
1276 painter.setPen(self.text_pen)
1277 painter.setBrush(self.remote_color)
1278 elif tag.startswith(remotes_prefix):
1279 tag = tag[remotes_len:]
1280 painter.setPen(self.text_pen)
1281 painter.setBrush(self.other_color)
1282 elif tag.startswith(tags_prefix):
1283 tag = tag[tags_len:]
1284 painter.setPen(self.text_pen)
1285 painter.setBrush(self.remote_color)
1286 elif tag.startswith(heads_prefix):
1287 tag = tag[heads_len:]
1288 painter.setPen(self.head_pen)
1289 painter.setBrush(self.head_color)
1290 else:
1291 painter.setPen(self.text_pen)
1292 painter.setBrush(self.other_color)
1294 text_rect = painter.boundingRect(
1295 QRectF(current_width, 0, 0, 0), Qt.TextSingleLine, tag)
1296 box_rect = text_rect.adjusted(-offset, -offset, offset, offset)
1298 painter.drawRoundedRect(box_rect, border, border)
1299 painter.drawText(text_rect, Qt.TextSingleLine, tag)
1300 current_width += text_rect.width() + spacing
1303 # pylint: disable=too-many-ancestors
1304 class GraphView(QtWidgets.QGraphicsView, ViewerMixin):
1306 diff_commits = Signal(object, object)
1308 x_adjust = int(Commit.commit_radius*4/3)
1309 y_adjust = int(Commit.commit_radius*4/3)
1311 x_off = -18
1312 y_off = -24
1314 def __init__(self, context, notifier, parent):
1315 QtWidgets.QGraphicsView.__init__(self, parent)
1316 ViewerMixin.__init__(self)
1318 highlight = self.palette().color(QtGui.QPalette.Highlight)
1319 Commit.commit_selected_color = highlight
1320 Commit.selected_outline_color = highlight.darker()
1322 self.context = context
1323 self.columns = {}
1324 self.selection_list = []
1325 self.menu_actions = None
1326 self.notifier = notifier
1327 self.commits = []
1328 self.items = {}
1329 self.mouse_start = [0, 0]
1330 self.saved_matrix = self.transform()
1331 self.max_column = 0
1332 self.min_column = 0
1333 self.frontier = {}
1334 self.tagged_cells = set()
1336 self.x_start = 24
1337 self.x_min = 24
1338 self.x_offsets = collections.defaultdict(lambda: self.x_min)
1340 self.is_panning = False
1341 self.pressed = False
1342 self.selecting = False
1343 self.last_mouse = [0, 0]
1344 self.zoom = 2
1345 self.setDragMode(self.RubberBandDrag)
1347 scene = QtWidgets.QGraphicsScene(self)
1348 scene.setItemIndexMethod(QtWidgets.QGraphicsScene.NoIndex)
1349 self.setScene(scene)
1351 self.setRenderHint(QtGui.QPainter.Antialiasing)
1352 self.setViewportUpdateMode(self.BoundingRectViewportUpdate)
1353 self.setCacheMode(QtWidgets.QGraphicsView.CacheBackground)
1354 self.setTransformationAnchor(QtWidgets.QGraphicsView.AnchorUnderMouse)
1355 self.setResizeAnchor(QtWidgets.QGraphicsView.NoAnchor)
1356 self.setBackgroundBrush(QtGui.QColor(Qt.white))
1358 qtutils.add_action(self, N_('Zoom In'), self.zoom_in,
1359 hotkeys.ZOOM_IN, hotkeys.ZOOM_IN_SECONDARY)
1361 qtutils.add_action(self, N_('Zoom Out'), self.zoom_out,
1362 hotkeys.ZOOM_OUT)
1364 qtutils.add_action(self, N_('Zoom to Fit'),
1365 self.zoom_to_fit, hotkeys.FIT)
1367 qtutils.add_action(self, N_('Select Parent'),
1368 self._select_parent, hotkeys.MOVE_DOWN_TERTIARY)
1370 qtutils.add_action(self, N_('Select Oldest Parent'),
1371 self._select_oldest_parent, hotkeys.MOVE_DOWN)
1373 qtutils.add_action(self, N_('Select Child'),
1374 self._select_child, hotkeys.MOVE_UP_TERTIARY)
1376 qtutils.add_action(self, N_('Select Newest Child'),
1377 self._select_newest_child, hotkeys.MOVE_UP)
1379 notifier.add_observer(diff.COMMITS_SELECTED, self.commits_selected)
1381 def clear(self):
1382 EdgeColor.reset()
1383 self.scene().clear()
1384 self.selection_list = []
1385 self.items.clear()
1386 self.x_offsets.clear()
1387 self.x_min = 24
1388 self.commits = []
1390 # ViewerMixin interface
1391 def selected_items(self):
1392 """Return the currently selected items"""
1393 return self.scene().selectedItems()
1395 def zoom_in(self):
1396 self.scale_view(1.5)
1398 def zoom_out(self):
1399 self.scale_view(1.0/1.5)
1401 def commits_selected(self, commits):
1402 if self.selecting:
1403 return
1404 self.select([commit.oid for commit in commits])
1406 def select(self, oids):
1407 """Select the item for the oids"""
1408 self.scene().clearSelection()
1409 for oid in oids:
1410 try:
1411 item = self.items[oid]
1412 except KeyError:
1413 continue
1414 item.blockSignals(True)
1415 item.setSelected(True)
1416 item.blockSignals(False)
1417 item_rect = item.sceneTransform().mapRect(item.boundingRect())
1418 self.ensureVisible(item_rect)
1420 def _get_item_by_generation(self, commits, criteria_fn):
1421 """Return the item for the commit matching criteria"""
1422 if not commits:
1423 return None
1424 generation = None
1425 for commit in commits:
1426 if (generation is None or
1427 criteria_fn(generation, commit.generation)):
1428 oid = commit.oid
1429 generation = commit.generation
1430 try:
1431 return self.items[oid]
1432 except KeyError:
1433 return None
1435 def _oldest_item(self, commits):
1436 """Return the item for the commit with the oldest generation number"""
1437 return self._get_item_by_generation(commits, lambda a, b: a > b)
1439 def _newest_item(self, commits):
1440 """Return the item for the commit with the newest generation number"""
1441 return self._get_item_by_generation(commits, lambda a, b: a < b)
1443 def create_patch(self):
1444 items = self.selected_items()
1445 if not items:
1446 return
1447 context = self.context
1448 selected_commits = sort_by_generation([n.commit for n in items])
1449 oids = [c.oid for c in selected_commits]
1450 all_oids = [c.oid for c in self.commits]
1451 cmds.do(cmds.FormatPatch, context, oids, all_oids)
1453 def _select_parent(self):
1454 """Select the parent with the newest generation number"""
1455 selected_item = self.selected_item()
1456 if selected_item is None:
1457 return
1458 parent_item = self._newest_item(selected_item.commit.parents)
1459 if parent_item is None:
1460 return
1461 selected_item.setSelected(False)
1462 parent_item.setSelected(True)
1463 self.ensureVisible(
1464 parent_item.mapRectToScene(parent_item.boundingRect()))
1466 def _select_oldest_parent(self):
1467 """Select the parent with the oldest generation number"""
1468 selected_item = self.selected_item()
1469 if selected_item is None:
1470 return
1471 parent_item = self._oldest_item(selected_item.commit.parents)
1472 if parent_item is None:
1473 return
1474 selected_item.setSelected(False)
1475 parent_item.setSelected(True)
1476 scene_rect = parent_item.mapRectToScene(parent_item.boundingRect())
1477 self.ensureVisible(scene_rect)
1479 def _select_child(self):
1480 """Select the child with the oldest generation number"""
1481 selected_item = self.selected_item()
1482 if selected_item is None:
1483 return
1484 child_item = self._oldest_item(selected_item.commit.children)
1485 if child_item is None:
1486 return
1487 selected_item.setSelected(False)
1488 child_item.setSelected(True)
1489 scene_rect = child_item.mapRectToScene(child_item.boundingRect())
1490 self.ensureVisible(scene_rect)
1492 def _select_newest_child(self):
1493 """Select the Nth child with the newest generation number (N > 1)"""
1494 selected_item = self.selected_item()
1495 if selected_item is None:
1496 return
1497 if len(selected_item.commit.children) > 1:
1498 children = selected_item.commit.children[1:]
1499 else:
1500 children = selected_item.commit.children
1501 child_item = self._newest_item(children)
1502 if child_item is None:
1503 return
1504 selected_item.setSelected(False)
1505 child_item.setSelected(True)
1506 scene_rect = child_item.mapRectToScene(child_item.boundingRect())
1507 self.ensureVisible(scene_rect)
1509 def set_initial_view(self):
1510 items = []
1511 selected = self.selected_items()
1512 if selected:
1513 items.extend(selected)
1515 if not selected and self.commits:
1516 commit = self.commits[-1]
1517 items.append(self.items[commit.oid])
1519 self.setSceneRect(self.scene().itemsBoundingRect())
1520 self.fit_view_to_items(items)
1522 def zoom_to_fit(self):
1523 """Fit selected items into the viewport"""
1525 items = self.selected_items()
1526 self.fit_view_to_items(items)
1528 def fit_view_to_items(self, items):
1529 if not items:
1530 rect = self.scene().itemsBoundingRect()
1531 else:
1532 x_min = y_min = maxsize
1533 x_max = y_max = -maxsize
1535 for item in items:
1536 pos = item.pos()
1537 x = pos.x()
1538 y = pos.y()
1539 x_min = min(x_min, x)
1540 x_max = max(x_max, x)
1541 y_min = min(y_min, y)
1542 y_max = max(y_max, y)
1544 rect = QtCore.QRectF(x_min, y_min,
1545 abs(x_max - x_min),
1546 abs(y_max - y_min))
1548 x_adjust = abs(GraphView.x_adjust)
1549 y_adjust = abs(GraphView.y_adjust)
1551 count = max(2.0, 10.0 - len(items)/2.0)
1552 y_offset = int(y_adjust * count)
1553 x_offset = int(x_adjust * count)
1554 rect.setX(rect.x() - x_offset//2)
1555 rect.setY(rect.y() - y_adjust//2)
1556 rect.setHeight(rect.height() + y_offset)
1557 rect.setWidth(rect.width() + x_offset)
1559 self.fitInView(rect, Qt.KeepAspectRatio)
1560 self.scene().invalidate()
1562 def save_selection(self, event):
1563 if event.button() != Qt.LeftButton:
1564 return
1565 elif Qt.ShiftModifier != event.modifiers():
1566 return
1567 self.selection_list = self.selected_items()
1569 def restore_selection(self, event):
1570 if Qt.ShiftModifier != event.modifiers():
1571 return
1572 for item in self.selection_list:
1573 item.setSelected(True)
1575 def handle_event(self, event_handler, event):
1576 self.save_selection(event)
1577 event_handler(self, event)
1578 self.restore_selection(event)
1579 self.update()
1581 def set_selecting(self, selecting):
1582 self.selecting = selecting
1584 def pan(self, event):
1585 pos = event.pos()
1586 dx = pos.x() - self.mouse_start[0]
1587 dy = pos.y() - self.mouse_start[1]
1589 if dx == 0 and dy == 0:
1590 return
1592 rect = QtCore.QRect(0, 0, abs(dx), abs(dy))
1593 delta = self.mapToScene(rect).boundingRect()
1595 tx = delta.width()
1596 if dx < 0.0:
1597 tx = -tx
1599 ty = delta.height()
1600 if dy < 0.0:
1601 ty = -ty
1603 matrix = self.transform()
1604 matrix.reset()
1605 matrix *= self.saved_matrix
1606 matrix.translate(tx, ty)
1608 self.setTransformationAnchor(QtWidgets.QGraphicsView.NoAnchor)
1609 self.setTransform(matrix)
1611 def wheel_zoom(self, event):
1612 """Handle mouse wheel zooming."""
1613 delta = qtcompat.wheel_delta(event)
1614 zoom = math.pow(2.0, delta/512.0)
1615 factor = (self.transform()
1616 .scale(zoom, zoom)
1617 .mapRect(QtCore.QRectF(0.0, 0.0, 1.0, 1.0))
1618 .width())
1619 if factor < 0.014 or factor > 42.0:
1620 return
1621 self.setTransformationAnchor(QtWidgets.QGraphicsView.AnchorUnderMouse)
1622 self.zoom = zoom
1623 self.scale(zoom, zoom)
1625 def wheel_pan(self, event):
1626 """Handle mouse wheel panning."""
1627 unit = QtCore.QRectF(0.0, 0.0, 1.0, 1.0)
1628 factor = 1.0 / self.transform().mapRect(unit).width()
1629 tx, ty = qtcompat.wheel_translation(event)
1631 matrix = self.transform().translate(tx * factor, ty * factor)
1632 self.setTransformationAnchor(QtWidgets.QGraphicsView.NoAnchor)
1633 self.setTransform(matrix)
1635 def scale_view(self, scale):
1636 factor = (self.transform()
1637 .scale(scale, scale)
1638 .mapRect(QtCore.QRectF(0, 0, 1, 1))
1639 .width())
1640 if factor < 0.07 or factor > 100.0:
1641 return
1642 self.zoom = scale
1644 adjust_scrollbars = True
1645 scrollbar = self.verticalScrollBar()
1646 if scrollbar:
1647 value = get(scrollbar)
1648 min_ = scrollbar.minimum()
1649 max_ = scrollbar.maximum()
1650 range_ = max_ - min_
1651 distance = value - min_
1652 nonzero_range = range_ > 0.1
1653 if nonzero_range:
1654 scrolloffset = distance/range_
1655 else:
1656 adjust_scrollbars = False
1658 self.setTransformationAnchor(QtWidgets.QGraphicsView.NoAnchor)
1659 self.scale(scale, scale)
1661 scrollbar = self.verticalScrollBar()
1662 if scrollbar and adjust_scrollbars:
1663 min_ = scrollbar.minimum()
1664 max_ = scrollbar.maximum()
1665 range_ = max_ - min_
1666 value = min_ + int(float(range_) * scrolloffset)
1667 scrollbar.setValue(value)
1669 def add_commits(self, commits):
1670 """Traverse commits and add them to the view."""
1671 self.commits.extend(commits)
1672 scene = self.scene()
1673 for commit in commits:
1674 item = Commit(commit, self.notifier)
1675 self.items[commit.oid] = item
1676 for ref in commit.tags:
1677 self.items[ref] = item
1678 scene.addItem(item)
1680 self.layout_commits()
1681 self.link(commits)
1683 def link(self, commits):
1684 """Create edges linking commits with their parents"""
1685 scene = self.scene()
1686 for commit in commits:
1687 try:
1688 commit_item = self.items[commit.oid]
1689 except KeyError:
1690 # TODO - Handle truncated history viewing
1691 continue
1692 for parent in reversed(commit.parents):
1693 try:
1694 parent_item = self.items[parent.oid]
1695 except KeyError:
1696 # TODO - Handle truncated history viewing
1697 continue
1698 try:
1699 edge = parent_item.edges[commit.oid]
1700 except KeyError:
1701 edge = Edge(parent_item, commit_item)
1702 else:
1703 continue
1704 parent_item.edges[commit.oid] = edge
1705 commit_item.edges[parent.oid] = edge
1706 scene.addItem(edge)
1708 def layout_commits(self):
1709 positions = self.position_nodes()
1711 # Each edge is accounted in two commits. Hence, accumulate invalid
1712 # edges to prevent double edge invalidation.
1713 invalid_edges = set()
1715 for oid, (x, y) in positions.items():
1716 item = self.items[oid]
1718 pos = item.pos()
1719 if pos != (x, y):
1720 item.setPos(x, y)
1722 for edge in item.edges.values():
1723 invalid_edges.add(edge)
1725 for edge in invalid_edges:
1726 edge.commits_were_invalidated()
1728 # Commit node layout technique
1730 # Nodes are aligned by a mesh. Columns and rows are distributed using
1731 # algorithms described below.
1733 # Row assignment algorithm
1735 # The algorithm aims consequent.
1736 # 1. A commit should be above all its parents.
1737 # 2. No commit should be at right side of a commit with a tag in same row.
1738 # This prevents overlapping of tag labels with commits and other labels.
1739 # 3. Commit density should be maximized.
1741 # The algorithm requires that all parents of a commit were assigned column.
1742 # Nodes must be traversed in generation ascend order. This guarantees that all
1743 # parents of a commit were assigned row. So, the algorithm may operate in
1744 # course of column assignment algorithm.
1746 # Row assignment uses frontier. A frontier is a dictionary that contains
1747 # minimum available row index for each column. It propagates during the
1748 # algorithm. Set of cells with tags is also maintained to meet second aim.
1750 # Initialization is performed by reset_rows method. Each new column should
1751 # be declared using declare_column method. Getting row for a cell is
1752 # implemented in alloc_cell method. Frontier must be propagated for any child
1753 # of fork commit which occupies different column. This meets first aim.
1755 # Column assignment algorithm
1757 # The algorithm traverses nodes in generation ascend order. This guarantees
1758 # that a node will be visited after all its parents.
1760 # The set of occupied columns are maintained during work. Initially it is
1761 # empty and no node occupied a column. Empty columns are allocated on demand.
1762 # Free index for column being allocated is searched in following way.
1763 # 1. Start from desired column and look towards graph center (0 column).
1764 # 2. Start from center and look in both directions simultaneously.
1765 # Desired column is defaulted to 0. Fork node should set desired column for
1766 # children equal to its one. This prevents branch from jumping too far from
1767 # its fork.
1769 # Initialization is performed by reset_columns method. Column allocation is
1770 # implemented in alloc_column method. Initialization and main loop are in
1771 # recompute_grid method. The method also embeds row assignment algorithm by
1772 # implementation.
1774 # Actions for each node are follow.
1775 # 1. If the node was not assigned a column then it is assigned empty one.
1776 # 2. Allocate row.
1777 # 3. Allocate columns for children.
1778 # If a child have a column assigned then it should no be overridden. One of
1779 # children is assigned same column as the node. If the node is a fork then the
1780 # child is chosen in generation descent order. This is a heuristic and it only
1781 # affects resulting appearance of the graph. Other children are assigned empty
1782 # columns in same order. It is the heuristic too.
1783 # 4. If no child occupies column of the node then leave it.
1784 # It is possible in consequent situations.
1785 # 4.1 The node is a leaf.
1786 # 4.2 The node is a fork and all its children are already assigned side
1787 # column. It is possible if all the children are merges.
1788 # 4.3 Single node child is a merge that is already assigned a column.
1789 # 5. Propagate frontier with respect to this node.
1790 # Each frontier entry corresponding to column occupied by any node's child
1791 # must be gather than node row index. This meets first aim of the row
1792 # assignment algorithm.
1793 # Note that frontier of child that occupies same row was propagated during
1794 # step 2. Hence, it must be propagated for children on side columns.
1796 def reset_columns(self):
1797 # Some children of displayed commits might not be accounted in
1798 # 'commits' list. It is common case during loading of big graph.
1799 # But, they are assigned a column that must be reseted. Hence, use
1800 # depth-first traversal to reset all columns assigned.
1801 for node in self.commits:
1802 if node.column is None:
1803 continue
1804 stack = [node]
1805 while stack:
1806 node = stack.pop()
1807 node.column = None
1808 for child in node.children:
1809 if child.column is not None:
1810 stack.append(child)
1812 self.columns = {}
1813 self.max_column = 0
1814 self.min_column = 0
1816 def reset_rows(self):
1817 self.frontier = {}
1818 self.tagged_cells = set()
1820 def declare_column(self, column):
1821 if self.frontier:
1822 # Align new column frontier by frontier of nearest column. If all
1823 # columns were left then select maximum frontier value.
1824 if not self.columns:
1825 self.frontier[column] = max(list(self.frontier.values()))
1826 return
1827 # This is heuristic that mostly affects roots. Note that the
1828 # frontier values for fork children will be overridden in course of
1829 # propagate_frontier.
1830 for offset in itertools.count(1):
1831 for c in [column + offset, column - offset]:
1832 if c not in self.columns:
1833 # Column 'c' is not occupied.
1834 continue
1835 try:
1836 frontier = self.frontier[c]
1837 except KeyError:
1838 # Column 'c' was never allocated.
1839 continue
1841 frontier -= 1
1842 # The frontier of the column may be higher because of
1843 # tag overlapping prevention performed for previous head.
1844 try:
1845 if self.frontier[column] >= frontier:
1846 break
1847 except KeyError:
1848 pass
1850 self.frontier[column] = frontier
1851 break
1852 else:
1853 continue
1854 break
1855 else:
1856 # First commit must be assigned 0 row.
1857 self.frontier[column] = 0
1859 def alloc_column(self, column=0):
1860 columns = self.columns
1861 # First, look for free column by moving from desired column to graph
1862 # center (column 0).
1863 for c in range(column, 0, -1 if column > 0 else 1):
1864 if c not in columns:
1865 if c > self.max_column:
1866 self.max_column = c
1867 elif c < self.min_column:
1868 self.min_column = c
1869 break
1870 else:
1871 # If no free column was found between graph center and desired
1872 # column then look for free one by moving from center along both
1873 # directions simultaneously.
1874 for c in itertools.count(0):
1875 if c not in columns:
1876 if c > self.max_column:
1877 self.max_column = c
1878 break
1879 c = -c
1880 if c not in columns:
1881 if c < self.min_column:
1882 self.min_column = c
1883 break
1884 self.declare_column(c)
1885 columns[c] = 1
1886 return c
1888 def alloc_cell(self, column, tags):
1889 # Get empty cell from frontier.
1890 cell_row = self.frontier[column]
1892 if tags:
1893 # Prevent overlapping of tag with cells already allocated a row.
1894 if self.x_off > 0:
1895 can_overlap = list(range(column + 1, self.max_column + 1))
1896 else:
1897 can_overlap = list(range(column - 1, self.min_column - 1, -1))
1898 for c in can_overlap:
1899 frontier = self.frontier[c]
1900 if frontier > cell_row:
1901 cell_row = frontier
1903 # Avoid overlapping with tags of commits at cell_row.
1904 if self.x_off > 0:
1905 can_overlap = list(range(self.min_column, column))
1906 else:
1907 can_overlap = list(range(self.max_column, column, -1))
1908 for cell_row in itertools.count(cell_row):
1909 for c in can_overlap:
1910 if (c, cell_row) in self.tagged_cells:
1911 # Overlapping. Try next row.
1912 break
1913 else:
1914 # No overlapping was found.
1915 break
1916 # Note that all checks should be made for new cell_row value.
1918 if tags:
1919 self.tagged_cells.add((column, cell_row))
1921 # Propagate frontier.
1922 self.frontier[column] = cell_row + 1
1923 return cell_row
1925 def propagate_frontier(self, column, value):
1926 current = self.frontier[column]
1927 if current < value:
1928 self.frontier[column] = value
1930 def leave_column(self, column):
1931 count = self.columns[column]
1932 if count == 1:
1933 del self.columns[column]
1934 else:
1935 self.columns[column] = count - 1
1937 def recompute_grid(self):
1938 self.reset_columns()
1939 self.reset_rows()
1941 for node in sort_by_generation(list(self.commits)):
1942 if node.column is None:
1943 # Node is either root or its parent is not in items. The last
1944 # happens when tree loading is in progress. Allocate new
1945 # columns for such nodes.
1946 node.column = self.alloc_column()
1948 node.row = self.alloc_cell(node.column, node.tags)
1950 # Allocate columns for children which are still without one. Also
1951 # propagate frontier for children.
1952 if node.is_fork():
1953 sorted_children = sorted(node.children,
1954 key=lambda c: c.generation,
1955 reverse=True)
1956 citer = iter(sorted_children)
1957 for child in citer:
1958 if child.column is None:
1959 # Top most child occupies column of parent.
1960 child.column = node.column
1961 # Note that frontier is propagated in course of
1962 # alloc_cell.
1963 break
1964 self.propagate_frontier(child.column, node.row + 1)
1965 else:
1966 # No child occupies same column.
1967 self.leave_column(node.column)
1968 # Note that the loop below will pass no iteration.
1970 # Rest children are allocated new column.
1971 for child in citer:
1972 if child.column is None:
1973 child.column = self.alloc_column(node.column)
1974 self.propagate_frontier(child.column, node.row + 1)
1975 elif node.children:
1976 child = node.children[0]
1977 if child.column is None:
1978 child.column = node.column
1979 # Note that frontier is propagated in course of alloc_cell.
1980 elif child.column != node.column:
1981 # Child node have other parents and occupies column of one
1982 # of them.
1983 self.leave_column(node.column)
1984 # But frontier must be propagated with respect to this
1985 # parent.
1986 self.propagate_frontier(child.column, node.row + 1)
1987 else:
1988 # This is a leaf node.
1989 self.leave_column(node.column)
1991 def position_nodes(self):
1992 self.recompute_grid()
1994 x_start = self.x_start
1995 x_min = self.x_min
1996 x_off = self.x_off
1997 y_off = self.y_off
1999 positions = {}
2001 for node in self.commits:
2002 x_pos = x_start + node.column * x_off
2003 y_pos = y_off + node.row * y_off
2005 positions[node.oid] = (x_pos, y_pos)
2006 x_min = min(x_min, x_pos)
2008 self.x_min = x_min
2010 return positions
2012 # Qt overrides
2013 def contextMenuEvent(self, event):
2014 self.context_menu_event(event)
2016 def mousePressEvent(self, event):
2017 if event.button() == Qt.MidButton:
2018 pos = event.pos()
2019 self.mouse_start = [pos.x(), pos.y()]
2020 self.saved_matrix = self.transform()
2021 self.is_panning = True
2022 return
2023 if event.button() == Qt.RightButton:
2024 event.ignore()
2025 return
2026 if event.button() == Qt.LeftButton:
2027 self.pressed = True
2028 self.handle_event(QtWidgets.QGraphicsView.mousePressEvent, event)
2030 def mouseMoveEvent(self, event):
2031 pos = self.mapToScene(event.pos())
2032 if self.is_panning:
2033 self.pan(event)
2034 return
2035 self.last_mouse[0] = pos.x()
2036 self.last_mouse[1] = pos.y()
2037 self.handle_event(QtWidgets.QGraphicsView.mouseMoveEvent, event)
2038 if self.pressed:
2039 self.viewport().repaint()
2041 def mouseReleaseEvent(self, event):
2042 self.pressed = False
2043 if event.button() == Qt.MidButton:
2044 self.is_panning = False
2045 return
2046 self.handle_event(QtWidgets.QGraphicsView.mouseReleaseEvent, event)
2047 self.selection_list = []
2048 self.viewport().repaint()
2050 def wheelEvent(self, event):
2051 """Handle Qt mouse wheel events."""
2052 if event.modifiers() & Qt.ControlModifier:
2053 self.wheel_zoom(event)
2054 else:
2055 self.wheel_pan(event)
2057 def fitInView(self, rect, flags=Qt.IgnoreAspectRatio):
2058 """Override fitInView to remove unwanted margins
2060 https://bugreports.qt.io/browse/QTBUG-42331 - based on QT sources
2063 if self.scene() is None or rect.isNull():
2064 return
2065 unity = self.transform().mapRect(QtCore.QRectF(0, 0, 1, 1))
2066 self.scale(1.0/unity.width(), 1.0/unity.height())
2067 view_rect = self.viewport().rect()
2068 scene_rect = self.transform().mapRect(rect)
2069 xratio = view_rect.width() / scene_rect.width()
2070 yratio = view_rect.height() / scene_rect.height()
2071 if flags == Qt.KeepAspectRatio:
2072 xratio = yratio = min(xratio, yratio)
2073 elif flags == Qt.KeepAspectRatioByExpanding:
2074 xratio = yratio = max(xratio, yratio)
2075 self.scale(xratio, yratio)
2076 self.centerOn(rect.center())
2079 def sort_by_generation(commits):
2080 if len(commits) < 2:
2081 return commits
2082 commits.sort(key=lambda x: x.generation)
2083 return commits
2086 # Glossary
2087 # ========
2088 # oid -- Git objects IDs (i.e. SHA-1 IDs)
2089 # ref -- Git references that resolve to a commit-ish (HEAD, branches, tags)