diff: set the tabwidth in the DiffWidget class
[git-cola.git] / cola / widgets / dag.py
blob9139ffa091d97071083a0c90ce336a15b2dab847
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 ..models import prefs
17 from ..qtutils import get
18 from .. import core
19 from .. import cmds
20 from .. import difftool
21 from .. import gitcmds
22 from .. import hotkeys
23 from .. import icons
24 from .. import observable
25 from .. import qtcompat
26 from .. import qtutils
27 from .. import utils
28 from . import archive
29 from . import browse
30 from . import completion
31 from . import createbranch
32 from . import createtag
33 from . import defs
34 from . import diff
35 from . import filelist
36 from . import standard
39 def git_dag(context, args=None, settings=None, existing_view=None, show=True):
40 """Return a pre-populated git DAG widget."""
41 model = context.model
42 branch = model.currentbranch
43 # disambiguate between branch names and filenames by using '--'
44 branch_doubledash = (branch + ' --') if branch else ''
45 params = dag.DAG(branch_doubledash, 1000)
46 params.set_arguments(args)
48 if existing_view is None:
49 view = GitDAG(context, params, settings=settings)
50 else:
51 view = existing_view
52 view.set_params(params)
53 if params.ref:
54 view.display()
55 if show:
56 view.show()
57 return view
60 class FocusRedirectProxy(object):
61 """Redirect actions from the main widget to child widgets"""
63 def __init__(self, *widgets):
64 """Provide proxied widgets; the default widget must be first"""
65 self.widgets = widgets
66 self.default = widgets[0]
68 def __getattr__(self, name):
69 return (lambda *args, **kwargs:
70 self._forward_action(name, *args, **kwargs))
72 def _forward_action(self, name, *args, **kwargs):
73 """Forward the captured action to the focused or default widget"""
74 widget = QtWidgets.QApplication.focusWidget()
75 if widget in self.widgets and hasattr(widget, name):
76 fn = getattr(widget, name)
77 else:
78 fn = getattr(self.default, name)
80 return fn(*args, **kwargs)
83 class ViewerMixin(object):
84 """Implementations must provide selected_items()"""
86 def __init__(self):
87 self.context = None # provided by implementation
88 self.selected = None
89 self.clicked = None
90 self.menu_actions = None # provided by implementation
92 def selected_item(self):
93 """Return the currently selected item"""
94 selected_items = self.selected_items()
95 if not selected_items:
96 return None
97 return selected_items[0]
99 def selected_oid(self):
100 item = self.selected_item()
101 if item is None:
102 result = None
103 else:
104 result = item.commit.oid
105 return result
107 def selected_oids(self):
108 return [i.commit for i in self.selected_items()]
110 def with_oid(self, fn):
111 oid = self.selected_oid()
112 if oid:
113 result = fn(oid)
114 else:
115 result = None
116 return result
118 def diff_selected_this(self):
119 clicked_oid = self.clicked.oid
120 selected_oid = self.selected.oid
121 self.diff_commits.emit(selected_oid, clicked_oid)
123 def diff_this_selected(self):
124 clicked_oid = self.clicked.oid
125 selected_oid = self.selected.oid
126 self.diff_commits.emit(clicked_oid, selected_oid)
128 def cherry_pick(self):
129 context = self.context
130 self.with_oid(lambda oid: cmds.do(cmds.CherryPick, context, [oid]))
132 def revert(self):
133 context = self.context
134 self.with_oid(lambda oid: cmds.do(cmds.Revert, context, oid))
136 def copy_to_clipboard(self):
137 self.with_oid(qtutils.set_clipboard)
139 def create_branch(self):
140 context = self.context
141 create_new_branch = partial(createbranch.create_new_branch, context)
142 self.with_oid(lambda oid: create_new_branch(revision=oid))
144 def create_tag(self):
145 context = self.context
146 self.with_oid(lambda oid: createtag.create_tag(context, ref=oid))
148 def create_tarball(self):
149 context = self.context
150 self.with_oid(
151 lambda oid: archive.show_save_dialog(context, oid, parent=self))
153 def show_diff(self):
154 context = self.context
155 self.with_oid(lambda oid:
156 difftool.diff_expression(context, self, oid + '^!',
157 hide_expr=False,
158 focus_tree=True))
160 def show_dir_diff(self):
161 context = self.context
162 self.with_oid(lambda oid:
163 cmds.difftool_launch(context, left=oid,
164 left_take_magic=True,
165 dir_diff=True))
167 def reset_branch_head(self):
168 context = self.context
169 self.with_oid(lambda oid:
170 cmds.do(cmds.ResetBranchHead, context, ref=oid))
172 def reset_worktree(self):
173 context = self.context
174 self.with_oid(lambda oid:
175 cmds.do(cmds.ResetWorktree, context, ref=oid))
177 def reset_merge(self):
178 context = self.context
179 self.with_oid(lambda oid: cmds.do(cmds.ResetMerge, context, ref=oid))
181 def reset_soft(self):
182 context = self.context
183 self.with_oid(lambda oid: cmds.do(cmds.ResetSoft, context, ref=oid))
185 def reset_hard(self):
186 context = self.context
187 self.with_oid(lambda oid: cmds.do(cmds.ResetHard, context, ref=oid))
189 def checkout_detached(self):
190 context = self.context
191 self.with_oid(lambda oid: cmds.do(cmds.Checkout, context, [oid]))
193 def save_blob_dialog(self):
194 context = self.context
195 self.with_oid(lambda oid: browse.BrowseBranch.browse(context, oid))
197 def update_menu_actions(self, event):
198 selected_items = self.selected_items()
199 item = self.itemAt(event.pos())
200 if item is None:
201 self.clicked = commit = None
202 else:
203 self.clicked = commit = item.commit
205 has_single_selection = len(selected_items) == 1
206 has_selection = bool(selected_items)
207 can_diff = bool(commit and has_single_selection and
208 commit is not selected_items[0].commit)
210 if can_diff:
211 self.selected = selected_items[0].commit
212 else:
213 self.selected = None
215 self.menu_actions['diff_this_selected'].setEnabled(can_diff)
216 self.menu_actions['diff_selected_this'].setEnabled(can_diff)
217 self.menu_actions['diff_commit'].setEnabled(has_single_selection)
218 self.menu_actions['diff_commit_all'].setEnabled(has_single_selection)
220 self.menu_actions['checkout_detached'].setEnabled(has_single_selection)
221 self.menu_actions['cherry_pick'].setEnabled(has_single_selection)
222 self.menu_actions['copy'].setEnabled(has_single_selection)
223 self.menu_actions['create_branch'].setEnabled(has_single_selection)
224 self.menu_actions['create_patch'].setEnabled(has_selection)
225 self.menu_actions['create_tag'].setEnabled(has_single_selection)
226 self.menu_actions['create_tarball'].setEnabled(has_single_selection)
227 self.menu_actions['reset_branch_head'].setEnabled(has_single_selection)
228 self.menu_actions['reset_worktree'].setEnabled(has_single_selection)
229 self.menu_actions['reset_merge'].setEnabled(has_single_selection)
230 self.menu_actions['reset_soft'].setEnabled(has_single_selection)
231 self.menu_actions['reset_hard'].setEnabled(has_single_selection)
232 self.menu_actions['revert'].setEnabled(has_single_selection)
233 self.menu_actions['save_blob'].setEnabled(has_single_selection)
235 def context_menu_event(self, event):
236 self.update_menu_actions(event)
237 menu = qtutils.create_menu(N_('Actions'), self)
238 menu.addAction(self.menu_actions['diff_this_selected'])
239 menu.addAction(self.menu_actions['diff_selected_this'])
240 menu.addAction(self.menu_actions['diff_commit'])
241 menu.addAction(self.menu_actions['diff_commit_all'])
242 menu.addSeparator()
243 menu.addAction(self.menu_actions['create_branch'])
244 menu.addAction(self.menu_actions['create_tag'])
245 menu.addSeparator()
246 menu.addAction(self.menu_actions['cherry_pick'])
247 menu.addAction(self.menu_actions['revert'])
248 menu.addAction(self.menu_actions['create_patch'])
249 menu.addAction(self.menu_actions['create_tarball'])
250 menu.addSeparator()
251 reset_menu = menu.addMenu(N_('Reset'))
252 reset_menu.addAction(self.menu_actions['reset_branch_head'])
253 reset_menu.addAction(self.menu_actions['reset_worktree'])
254 reset_menu.addSeparator()
255 reset_menu.addAction(self.menu_actions['reset_merge'])
256 reset_menu.addAction(self.menu_actions['reset_soft'])
257 reset_menu.addAction(self.menu_actions['reset_hard'])
258 menu.addAction(self.menu_actions['checkout_detached'])
259 menu.addSeparator()
260 menu.addAction(self.menu_actions['save_blob'])
261 menu.addAction(self.menu_actions['copy'])
262 menu.exec_(self.mapToGlobal(event.pos()))
265 def viewer_actions(widget):
266 return {
267 'diff_this_selected':
268 qtutils.add_action(widget, N_('Diff this -> selected'),
269 widget.proxy.diff_this_selected),
270 'diff_selected_this':
271 qtutils.add_action(widget, N_('Diff selected -> this'),
272 widget.proxy.diff_selected_this),
273 'create_branch':
274 qtutils.add_action(widget, N_('Create Branch'),
275 widget.proxy.create_branch),
276 'create_patch':
277 qtutils.add_action(widget, N_('Create Patch'),
278 widget.proxy.create_patch),
279 'create_tag':
280 qtutils.add_action(widget, N_('Create Tag'),
281 widget.proxy.create_tag),
282 'create_tarball':
283 qtutils.add_action(widget, N_('Save As Tarball/Zip...'),
284 widget.proxy.create_tarball),
285 'cherry_pick':
286 qtutils.add_action(widget, N_('Cherry Pick'),
287 widget.proxy.cherry_pick),
288 'revert':
289 qtutils.add_action(widget, N_('Revert'),
290 widget.proxy.revert),
291 'diff_commit':
292 qtutils.add_action(widget, N_('Launch Diff Tool'),
293 widget.proxy.show_diff, hotkeys.DIFF),
294 'diff_commit_all':
295 qtutils.add_action(widget, N_('Launch Directory Diff Tool'),
296 widget.proxy.show_dir_diff, hotkeys.DIFF_SECONDARY),
297 'checkout_detached':
298 qtutils.add_action(widget, N_('Checkout Detached HEAD'),
299 widget.proxy.checkout_detached),
300 'reset_branch_head':
301 qtutils.add_action(widget, N_('Reset Branch Head'),
302 widget.proxy.reset_branch_head),
303 'reset_worktree':
304 qtutils.add_action(widget, N_('Reset Worktree'),
305 widget.proxy.reset_worktree),
306 'reset_merge':
307 qtutils.add_action(widget, N_('Reset Merge'),
308 widget.proxy.reset_merge),
309 'reset_soft':
310 qtutils.add_action(widget, N_('Reset Soft'),
311 widget.proxy.reset_soft),
312 'reset_hard':
313 qtutils.add_action(widget, N_('Reset Hard'),
314 widget.proxy.reset_hard),
315 'save_blob':
316 qtutils.add_action(widget, N_('Grab File...'),
317 widget.proxy.save_blob_dialog),
318 'copy':
319 qtutils.add_action(widget, N_('Copy SHA-1'),
320 widget.proxy.copy_to_clipboard,
321 hotkeys.COPY_SHA1),
325 class CommitTreeWidgetItem(QtWidgets.QTreeWidgetItem):
327 def __init__(self, commit, parent=None):
328 QtWidgets.QTreeWidgetItem.__init__(self, parent)
329 self.commit = commit
330 self.setText(0, commit.summary)
331 self.setText(1, commit.author)
332 self.setText(2, commit.authdate)
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)
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 self.maxresults.editingFinished.connect(self.display)
626 self.revtext.textChanged.connect(self.text_changed)
627 self.revtext.activated.connect(self.display)
628 self.revtext.enter.connect(self.display)
629 self.revtext.down.connect(self.focus_tree)
631 # The model is updated in another thread so use
632 # signals/slots to bring control back to the main GUI thread
633 self.model.add_observer(self.model.message_updated, self.updated.emit)
634 self.updated.connect(self.model_updated, type=Qt.QueuedConnection)
636 qtutils.add_action(self, 'Focus', self.focus_input, hotkeys.FOCUS)
637 qtutils.add_close_action(self)
639 self.set_params(params)
641 def set_params(self, params):
642 context = self.context
643 self.params = params
645 # Update fields affected by model
646 self.revtext.setText(params.ref)
647 self.maxresults.setValue(params.count)
648 self.update_window_title()
650 if self.thread is not None:
651 self.thread.stop()
653 self.thread = ReaderThread(context, params, self)
655 thread = self.thread
656 thread.begin.connect(self.thread_begin, type=Qt.QueuedConnection)
657 thread.status.connect(self.thread_status, type=Qt.QueuedConnection)
658 thread.add.connect(self.add_commits, type=Qt.QueuedConnection)
659 thread.end.connect(self.thread_end, type=Qt.QueuedConnection)
661 def focus_input(self):
662 self.revtext.setFocus()
664 def focus_tree(self):
665 self.treewidget.setFocus()
667 def text_changed(self, txt):
668 self.params.ref = txt
669 self.update_window_title()
671 def update_window_title(self):
672 project = self.model.project
673 if self.params.ref:
674 self.setWindowTitle(N_('%(project)s: %(ref)s - DAG')
675 % dict(project=project, ref=self.params.ref))
676 else:
677 self.setWindowTitle(project + N_(' - DAG'))
679 def export_state(self):
680 state = standard.MainWindow.export_state(self)
681 state['count'] = self.params.count
682 state['log'] = self.treewidget.export_state()
683 return state
685 def apply_state(self, state):
686 result = standard.MainWindow.apply_state(self, state)
687 try:
688 count = state['count']
689 if self.params.overridden('count'):
690 count = self.params.count
691 except (KeyError, TypeError, ValueError, AttributeError):
692 count = self.params.count
693 result = False
694 self.params.set_count(count)
695 self.lock_layout_action.setChecked(state.get('lock_layout', False))
697 try:
698 log_state = state['log']
699 except (KeyError, ValueError):
700 log_state = None
701 if log_state:
702 self.treewidget.apply_state(log_state)
704 return result
706 def model_updated(self):
707 self.display()
709 def refresh(self):
710 """Unconditionally refresh the DAG"""
711 # self.force_refresh triggers an Unconditional redraw
712 self.force_refresh = True
713 cmds.do(cmds.Refresh, self.context)
714 self.force_refresh = False
716 def display(self):
717 """Update the view when the Git refs change"""
718 ref = get(self.revtext)
719 count = get(self.maxresults)
720 context = self.context
721 model = self.model
722 # The DAG tries to avoid updating when the object IDs have not
723 # changed. Without doing this the DAG constantly redraws itself
724 # whenever inotify sends update events, which hurts usability.
726 # To minimize redraws we leverage `git rev-parse`. The strategy is to
727 # use `git rev-parse` on the input line, which converts each argument
728 # into object IDs. From there it's a simple matter of detecting when
729 # the object IDs changed.
731 # In addition to object IDs, we also need to know when the set of
732 # named references (branches, tags) changes so that an update is
733 # triggered when new branches and tags are created.
734 refs = set(model.local_branches + model.remote_branches + model.tags)
735 argv = utils.shell_split(ref or 'HEAD')
736 oids = gitcmds.parse_refs(context, argv)
737 update = (self.force_refresh
738 or count != self.old_count
739 or oids != self.old_oids
740 or refs != self.old_refs)
741 if update:
742 self.thread.stop()
743 self.params.set_ref(ref)
744 self.params.set_count(count)
745 self.thread.start()
747 self.old_oids = oids
748 self.old_count = count
749 self.old_refs = refs
751 def commits_selected(self, commits):
752 if commits:
753 self.selection = commits
755 def clear(self):
756 self.commits.clear()
757 self.commit_list = []
758 self.graphview.clear()
759 self.treewidget.clear()
761 def add_commits(self, commits):
762 self.commit_list.extend(commits)
763 # Keep track of commits
764 for commit_obj in commits:
765 self.commits[commit_obj.oid] = commit_obj
766 for tag in commit_obj.tags:
767 self.commits[tag] = commit_obj
768 self.graphview.add_commits(commits)
769 self.treewidget.add_commits(commits)
771 def thread_begin(self):
772 self.clear()
774 def thread_end(self):
775 self.restore_selection()
777 def thread_status(self, successful):
778 self.revtext.hint.set_error(not successful)
780 def restore_selection(self):
781 selection = self.selection
782 try:
783 commit_obj = self.commit_list[-1]
784 except IndexError:
785 # No commits, exist, early-out
786 return
788 new_commits = [self.commits.get(s.oid, None) for s in selection]
789 new_commits = [c for c in new_commits if c is not None]
790 if new_commits:
791 # The old selection exists in the new state
792 self.notifier.notify_observers(diff.COMMITS_SELECTED, new_commits)
793 else:
794 # The old selection is now empty. Select the top-most commit
795 self.notifier.notify_observers(diff.COMMITS_SELECTED, [commit_obj])
797 self.graphview.set_initial_view()
799 def diff_commits(self, a, b):
800 paths = self.params.paths()
801 if paths:
802 cmds.difftool_launch(self.context, left=a, right=b, paths=paths)
803 else:
804 difftool.diff_commits(self.context, self, a, b)
806 # Qt overrides
807 def closeEvent(self, event):
808 self.revtext.close_popup()
809 self.thread.stop()
810 standard.MainWindow.closeEvent(self, event)
812 def histories_selected(self, histories):
813 argv = [self.model.currentbranch, '--']
814 argv.extend(histories)
815 text = core.list2cmdline(argv)
816 self.revtext.setText(text)
817 self.display()
819 def difftool_selected(self, files):
820 bottom, top = self.treewidget.selected_commit_range()
821 if not top:
822 return
823 cmds.difftool_launch(self.context, left=bottom, left_take_parent=True,
824 right=top, paths=files)
826 def grab_file(self, filename):
827 """Save the selected file from the filelist widget"""
828 oid = self.treewidget.selected_oid()
829 model = browse.BrowseModel(oid, filename=filename)
830 browse.save_path(self.context, filename, model)
833 class ReaderThread(QtCore.QThread):
834 begin = Signal()
835 add = Signal(object)
836 end = Signal()
837 status = Signal(object)
839 def __init__(self, context, params, parent):
840 QtCore.QThread.__init__(self, parent)
841 self.context = context
842 self.params = params
843 self._abort = False
844 self._stop = False
845 self._mutex = QtCore.QMutex()
846 self._condition = QtCore.QWaitCondition()
848 def run(self):
849 context = self.context
850 repo = dag.RepoReader(context, self.params)
851 repo.reset()
852 self.begin.emit()
853 commits = []
854 for c in repo.get():
855 self._mutex.lock()
856 if self._stop:
857 self._condition.wait(self._mutex)
858 self._mutex.unlock()
859 if self._abort:
860 repo.reset()
861 return
862 commits.append(c)
863 if len(commits) >= 512:
864 self.add.emit(commits)
865 commits = []
867 self.status.emit(repo.returncode == 0)
868 if commits:
869 self.add.emit(commits)
870 self.end.emit()
872 def start(self):
873 self._abort = False
874 self._stop = False
875 QtCore.QThread.start(self)
877 def pause(self):
878 self._mutex.lock()
879 self._stop = True
880 self._mutex.unlock()
882 def resume(self):
883 self._mutex.lock()
884 self._stop = False
885 self._mutex.unlock()
886 self._condition.wakeOne()
888 def stop(self):
889 self._abort = True
890 self.wait()
893 class Cache(object):
895 _label_font = None
897 @classmethod
898 def label_font(cls):
899 font = cls._label_font
900 if font is None:
901 font = cls._label_font = QtWidgets.QApplication.font()
902 font.setPointSize(6)
903 return font
906 class Edge(QtWidgets.QGraphicsItem):
907 item_type = QtWidgets.QGraphicsItem.UserType + 1
909 def __init__(self, source, dest):
911 QtWidgets.QGraphicsItem.__init__(self)
913 self.setAcceptedMouseButtons(Qt.NoButton)
914 self.source = source
915 self.dest = dest
916 self.commit = source.commit
917 self.setZValue(-2)
919 self.recompute_bound()
920 self.path = None
921 self.path_valid = False
923 # Choose a new color for new branch edges
924 if self.source.x() < self.dest.x():
925 color = EdgeColor.cycle()
926 line = Qt.SolidLine
927 elif self.source.x() != self.dest.x():
928 color = EdgeColor.current()
929 line = Qt.SolidLine
930 else:
931 color = EdgeColor.current()
932 line = Qt.SolidLine
934 self.pen = QtGui.QPen(color, 4.0, line, Qt.SquareCap, Qt.RoundJoin)
936 def recompute_bound(self):
937 dest_pt = Commit.item_bbox.center()
939 self.source_pt = self.mapFromItem(self.source, dest_pt)
940 self.dest_pt = self.mapFromItem(self.dest, dest_pt)
941 self.line = QtCore.QLineF(self.source_pt, self.dest_pt)
943 width = self.dest_pt.x() - self.source_pt.x()
944 height = self.dest_pt.y() - self.source_pt.y()
945 rect = QtCore.QRectF(self.source_pt, QtCore.QSizeF(width, height))
946 self.bound = rect.normalized()
948 def commits_were_invalidated(self):
949 self.recompute_bound()
950 self.prepareGeometryChange()
951 # The path should not be recomputed immediately because just small part
952 # of DAG is actually shown at same time. It will be recomputed on
953 # demand in course of 'paint' method.
954 self.path_valid = False
955 # Hence, just queue redrawing.
956 self.update()
958 # Qt overrides
959 def type(self):
960 return self.item_type
962 def boundingRect(self):
963 return self.bound
965 def recompute_path(self):
966 QRectF = QtCore.QRectF
967 QPointF = QtCore.QPointF
969 arc_rect = 10
970 connector_length = 5
972 path = QtGui.QPainterPath()
974 if self.source.x() == self.dest.x():
975 path.moveTo(self.source.x(), self.source.y())
976 path.lineTo(self.dest.x(), self.dest.y())
977 else:
978 # Define points starting from source
979 point1 = QPointF(self.source.x(), self.source.y())
980 point2 = QPointF(point1.x(), point1.y() - connector_length)
981 point3 = QPointF(point2.x() + arc_rect, point2.y() - arc_rect)
983 # Define points starting from dest
984 point4 = QPointF(self.dest.x(), self.dest.y())
985 point5 = QPointF(point4.x(), point3.y() - arc_rect)
986 point6 = QPointF(point5.x() - arc_rect, point5.y() + arc_rect)
988 start_angle_arc1 = 180
989 span_angle_arc1 = 90
990 start_angle_arc2 = 90
991 span_angle_arc2 = -90
993 # If the dest is at the left of the source, then we
994 # need to reverse some values
995 if self.source.x() > self.dest.x():
996 point5 = QPointF(point4.x(), point4.y() + connector_length)
997 point6 = QPointF(point5.x() + arc_rect, point5.y() + arc_rect)
998 point3 = QPointF(self.source.x() - arc_rect, point6.y())
999 point2 = QPointF(self.source.x(), point3.y() + arc_rect)
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 class GraphView(QtWidgets.QGraphicsView, ViewerMixin):
1305 diff_commits = Signal(object, object)
1307 x_adjust = int(Commit.commit_radius*4/3)
1308 y_adjust = int(Commit.commit_radius*4/3)
1310 x_off = -18
1311 y_off = -24
1313 def __init__(self, context, notifier, parent):
1314 QtWidgets.QGraphicsView.__init__(self, parent)
1315 ViewerMixin.__init__(self)
1317 highlight = self.palette().color(QtGui.QPalette.Highlight)
1318 Commit.commit_selected_color = highlight
1319 Commit.selected_outline_color = highlight.darker()
1321 self.context = context
1322 self.columns = {}
1323 self.selection_list = []
1324 self.menu_actions = None
1325 self.notifier = notifier
1326 self.commits = []
1327 self.items = {}
1328 self.mouse_start = [0, 0]
1329 self.saved_matrix = self.transform()
1330 self.max_column = 0
1331 self.min_column = 0
1332 self.frontier = {}
1333 self.tagged_cells = set()
1335 self.x_start = 24
1336 self.x_min = 24
1337 self.x_offsets = collections.defaultdict(lambda: self.x_min)
1339 self.is_panning = False
1340 self.pressed = False
1341 self.selecting = False
1342 self.last_mouse = [0, 0]
1343 self.zoom = 2
1344 self.setDragMode(self.RubberBandDrag)
1346 scene = QtWidgets.QGraphicsScene(self)
1347 scene.setItemIndexMethod(QtWidgets.QGraphicsScene.NoIndex)
1348 self.setScene(scene)
1350 self.setRenderHint(QtGui.QPainter.Antialiasing)
1351 self.setViewportUpdateMode(self.BoundingRectViewportUpdate)
1352 self.setCacheMode(QtWidgets.QGraphicsView.CacheBackground)
1353 self.setTransformationAnchor(QtWidgets.QGraphicsView.AnchorUnderMouse)
1354 self.setResizeAnchor(QtWidgets.QGraphicsView.NoAnchor)
1355 self.setBackgroundBrush(QtGui.QColor(Qt.white))
1357 qtutils.add_action(self, N_('Zoom In'), self.zoom_in,
1358 hotkeys.ZOOM_IN, hotkeys.ZOOM_IN_SECONDARY)
1360 qtutils.add_action(self, N_('Zoom Out'), self.zoom_out,
1361 hotkeys.ZOOM_OUT)
1363 qtutils.add_action(self, N_('Zoom to Fit'),
1364 self.zoom_to_fit, hotkeys.FIT)
1366 qtutils.add_action(self, N_('Select Parent'),
1367 self._select_parent, hotkeys.MOVE_DOWN_TERTIARY)
1369 qtutils.add_action(self, N_('Select Oldest Parent'),
1370 self._select_oldest_parent, hotkeys.MOVE_DOWN)
1372 qtutils.add_action(self, N_('Select Child'),
1373 self._select_child, hotkeys.MOVE_UP_TERTIARY)
1375 qtutils.add_action(self, N_('Select Newest Child'),
1376 self._select_newest_child, hotkeys.MOVE_UP)
1378 notifier.add_observer(diff.COMMITS_SELECTED, self.commits_selected)
1380 def clear(self):
1381 EdgeColor.reset()
1382 self.scene().clear()
1383 self.selection_list = []
1384 self.items.clear()
1385 self.x_offsets.clear()
1386 self.x_min = 24
1387 self.commits = []
1389 # ViewerMixin interface
1390 def selected_items(self):
1391 """Return the currently selected items"""
1392 return self.scene().selectedItems()
1394 def zoom_in(self):
1395 self.scale_view(1.5)
1397 def zoom_out(self):
1398 self.scale_view(1.0/1.5)
1400 def commits_selected(self, commits):
1401 if self.selecting:
1402 return
1403 self.select([commit.oid for commit in commits])
1405 def select(self, oids):
1406 """Select the item for the oids"""
1407 self.scene().clearSelection()
1408 for oid in oids:
1409 try:
1410 item = self.items[oid]
1411 except KeyError:
1412 continue
1413 item.blockSignals(True)
1414 item.setSelected(True)
1415 item.blockSignals(False)
1416 item_rect = item.sceneTransform().mapRect(item.boundingRect())
1417 self.ensureVisible(item_rect)
1419 def _get_item_by_generation(self, commits, criteria_fn):
1420 """Return the item for the commit matching criteria"""
1421 if not commits:
1422 return None
1423 generation = None
1424 for commit in commits:
1425 if (generation is None or
1426 criteria_fn(generation, commit.generation)):
1427 oid = commit.oid
1428 generation = commit.generation
1429 try:
1430 return self.items[oid]
1431 except KeyError:
1432 return None
1434 def _oldest_item(self, commits):
1435 """Return the item for the commit with the oldest generation number"""
1436 return self._get_item_by_generation(commits, lambda a, b: a > b)
1438 def _newest_item(self, commits):
1439 """Return the item for the commit with the newest generation number"""
1440 return self._get_item_by_generation(commits, lambda a, b: a < b)
1442 def create_patch(self):
1443 items = self.selected_items()
1444 if not items:
1445 return
1446 context = self.context
1447 selected_commits = sort_by_generation([n.commit for n in items])
1448 oids = [c.oid for c in selected_commits]
1449 all_oids = [c.oid for c in self.commits]
1450 cmds.do(cmds.FormatPatch, context, oids, all_oids)
1452 def _select_parent(self):
1453 """Select the parent with the newest generation number"""
1454 selected_item = self.selected_item()
1455 if selected_item is None:
1456 return
1457 parent_item = self._newest_item(selected_item.commit.parents)
1458 if parent_item is None:
1459 return
1460 selected_item.setSelected(False)
1461 parent_item.setSelected(True)
1462 self.ensureVisible(
1463 parent_item.mapRectToScene(parent_item.boundingRect()))
1465 def _select_oldest_parent(self):
1466 """Select the parent with the oldest generation number"""
1467 selected_item = self.selected_item()
1468 if selected_item is None:
1469 return
1470 parent_item = self._oldest_item(selected_item.commit.parents)
1471 if parent_item is None:
1472 return
1473 selected_item.setSelected(False)
1474 parent_item.setSelected(True)
1475 scene_rect = parent_item.mapRectToScene(parent_item.boundingRect())
1476 self.ensureVisible(scene_rect)
1478 def _select_child(self):
1479 """Select the child with the oldest generation number"""
1480 selected_item = self.selected_item()
1481 if selected_item is None:
1482 return
1483 child_item = self._oldest_item(selected_item.commit.children)
1484 if child_item is None:
1485 return
1486 selected_item.setSelected(False)
1487 child_item.setSelected(True)
1488 scene_rect = child_item.mapRectToScene(child_item.boundingRect())
1489 self.ensureVisible(scene_rect)
1491 def _select_newest_child(self):
1492 """Select the Nth child with the newest generation number (N > 1)"""
1493 selected_item = self.selected_item()
1494 if selected_item is None:
1495 return
1496 if len(selected_item.commit.children) > 1:
1497 children = selected_item.commit.children[1:]
1498 else:
1499 children = selected_item.commit.children
1500 child_item = self._newest_item(children)
1501 if child_item is None:
1502 return
1503 selected_item.setSelected(False)
1504 child_item.setSelected(True)
1505 scene_rect = child_item.mapRectToScene(child_item.boundingRect())
1506 self.ensureVisible(scene_rect)
1508 def set_initial_view(self):
1509 self_commits = self.commits
1510 self_items = self.items
1512 commits = self_commits[-7:]
1513 items = [self_items[c.oid] for c in commits]
1515 selected = self.selected_items()
1516 if selected:
1517 items.extend(selected)
1519 self.fit_view_to_items(items)
1521 def zoom_to_fit(self):
1522 """Fit selected items into the viewport"""
1524 items = self.selected_items()
1525 self.fit_view_to_items(items)
1527 def fit_view_to_items(self, items):
1528 if not items:
1529 rect = self.scene().itemsBoundingRect()
1530 else:
1531 x_min = y_min = maxsize
1532 x_max = y_max = -maxsize
1534 for item in items:
1535 pos = item.pos()
1536 x = pos.x()
1537 y = pos.y()
1538 x_min = min(x_min, x)
1539 x_max = max(x_max, x)
1540 y_min = min(y_min, y)
1541 y_max = max(y_max, y)
1543 rect = QtCore.QRectF(x_min, y_min,
1544 abs(x_max - x_min),
1545 abs(y_max - y_min))
1547 x_adjust = abs(GraphView.x_adjust)
1548 y_adjust = abs(GraphView.y_adjust)
1550 count = max(2.0, 10.0 - len(items)/2.0)
1551 y_offset = int(y_adjust * count)
1552 x_offset = int(x_adjust * count)
1553 rect.setX(rect.x() - x_offset//2)
1554 rect.setY(rect.y() - y_adjust//2)
1555 rect.setHeight(rect.height() + y_offset)
1556 rect.setWidth(rect.width() + x_offset)
1558 self.fitInView(rect, Qt.KeepAspectRatio)
1559 self.scene().invalidate()
1561 def save_selection(self, event):
1562 if event.button() != Qt.LeftButton:
1563 return
1564 elif Qt.ShiftModifier != event.modifiers():
1565 return
1566 self.selection_list = self.selected_items()
1568 def restore_selection(self, event):
1569 if Qt.ShiftModifier != event.modifiers():
1570 return
1571 for item in self.selection_list:
1572 item.setSelected(True)
1574 def handle_event(self, event_handler, event):
1575 self.save_selection(event)
1576 event_handler(self, event)
1577 self.restore_selection(event)
1578 self.update()
1580 def set_selecting(self, selecting):
1581 self.selecting = selecting
1583 def pan(self, event):
1584 pos = event.pos()
1585 dx = pos.x() - self.mouse_start[0]
1586 dy = pos.y() - self.mouse_start[1]
1588 if dx == 0 and dy == 0:
1589 return
1591 rect = QtCore.QRect(0, 0, abs(dx), abs(dy))
1592 delta = self.mapToScene(rect).boundingRect()
1594 tx = delta.width()
1595 if dx < 0.0:
1596 tx = -tx
1598 ty = delta.height()
1599 if dy < 0.0:
1600 ty = -ty
1602 matrix = self.transform()
1603 matrix.reset()
1604 matrix *= self.saved_matrix
1605 matrix.translate(tx, ty)
1607 self.setTransformationAnchor(QtWidgets.QGraphicsView.NoAnchor)
1608 self.setTransform(matrix)
1610 def wheel_zoom(self, event):
1611 """Handle mouse wheel zooming."""
1612 delta = qtcompat.wheel_delta(event)
1613 zoom = math.pow(2.0, delta/512.0)
1614 factor = (self.transform()
1615 .scale(zoom, zoom)
1616 .mapRect(QtCore.QRectF(0.0, 0.0, 1.0, 1.0))
1617 .width())
1618 if factor < 0.014 or factor > 42.0:
1619 return
1620 self.setTransformationAnchor(QtWidgets.QGraphicsView.AnchorUnderMouse)
1621 self.zoom = zoom
1622 self.scale(zoom, zoom)
1624 def wheel_pan(self, event):
1625 """Handle mouse wheel panning."""
1626 unit = QtCore.QRectF(0.0, 0.0, 1.0, 1.0)
1627 factor = 1.0 / self.transform().mapRect(unit).width()
1628 tx, ty = qtcompat.wheel_translation(event)
1630 matrix = self.transform().translate(tx * factor, ty * factor)
1631 self.setTransformationAnchor(QtWidgets.QGraphicsView.NoAnchor)
1632 self.setTransform(matrix)
1634 def scale_view(self, scale):
1635 factor = (self.transform()
1636 .scale(scale, scale)
1637 .mapRect(QtCore.QRectF(0, 0, 1, 1))
1638 .width())
1639 if factor < 0.07 or factor > 100.0:
1640 return
1641 self.zoom = scale
1643 adjust_scrollbars = True
1644 scrollbar = self.verticalScrollBar()
1645 if scrollbar:
1646 value = get(scrollbar)
1647 min_ = scrollbar.minimum()
1648 max_ = scrollbar.maximum()
1649 range_ = max_ - min_
1650 distance = value - min_
1651 nonzero_range = range_ > 0.1
1652 if nonzero_range:
1653 scrolloffset = distance/range_
1654 else:
1655 adjust_scrollbars = False
1657 self.setTransformationAnchor(QtWidgets.QGraphicsView.NoAnchor)
1658 self.scale(scale, scale)
1660 scrollbar = self.verticalScrollBar()
1661 if scrollbar and adjust_scrollbars:
1662 min_ = scrollbar.minimum()
1663 max_ = scrollbar.maximum()
1664 range_ = max_ - min_
1665 value = min_ + int(float(range_) * scrolloffset)
1666 scrollbar.setValue(value)
1668 def add_commits(self, commits):
1669 """Traverse commits and add them to the view."""
1670 self.commits.extend(commits)
1671 scene = self.scene()
1672 for commit in commits:
1673 item = Commit(commit, self.notifier)
1674 self.items[commit.oid] = item
1675 for ref in commit.tags:
1676 self.items[ref] = item
1677 scene.addItem(item)
1679 self.layout_commits()
1680 self.link(commits)
1682 def link(self, commits):
1683 """Create edges linking commits with their parents"""
1684 scene = self.scene()
1685 for commit in commits:
1686 try:
1687 commit_item = self.items[commit.oid]
1688 except KeyError:
1689 # TODO - Handle truncated history viewing
1690 continue
1691 for parent in reversed(commit.parents):
1692 try:
1693 parent_item = self.items[parent.oid]
1694 except KeyError:
1695 # TODO - Handle truncated history viewing
1696 continue
1697 try:
1698 edge = parent_item.edges[commit.oid]
1699 except KeyError:
1700 edge = Edge(parent_item, commit_item)
1701 else:
1702 continue
1703 parent_item.edges[commit.oid] = edge
1704 commit_item.edges[parent.oid] = edge
1705 scene.addItem(edge)
1707 def layout_commits(self):
1708 positions = self.position_nodes()
1710 # Each edge is accounted in two commits. Hence, accumulate invalid
1711 # edges to prevent double edge invalidation.
1712 invalid_edges = set()
1714 for oid, (x, y) in positions.items():
1715 item = self.items[oid]
1717 pos = item.pos()
1718 if pos != (x, y):
1719 item.setPos(x, y)
1721 for edge in item.edges.values():
1722 invalid_edges.add(edge)
1724 for edge in invalid_edges:
1725 edge.commits_were_invalidated()
1727 # Commit node layout technique
1729 # Nodes are aligned by a mesh. Columns and rows are distributed using
1730 # algorithms described below.
1732 # Row assignment algorithm
1734 # The algorithm aims consequent.
1735 # 1. A commit should be above all its parents.
1736 # 2. No commit should be at right side of a commit with a tag in same row.
1737 # This prevents overlapping of tag labels with commits and other labels.
1738 # 3. Commit density should be maximized.
1740 # The algorithm requires that all parents of a commit were assigned column.
1741 # Nodes must be traversed in generation ascend order. This guarantees that all
1742 # parents of a commit were assigned row. So, the algorithm may operate in
1743 # course of column assignment algorithm.
1745 # Row assignment uses frontier. A frontier is a dictionary that contains
1746 # minimum available row index for each column. It propagates during the
1747 # algorithm. Set of cells with tags is also maintained to meet second aim.
1749 # Initialization is performed by reset_rows method. Each new column should
1750 # be declared using declare_column method. Getting row for a cell is
1751 # implemented in alloc_cell method. Frontier must be propagated for any child
1752 # of fork commit which occupies different column. This meets first aim.
1754 # Column assignment algorithm
1756 # The algorithm traverses nodes in generation ascend order. This guarantees
1757 # that a node will be visited after all its parents.
1759 # The set of occupied columns are maintained during work. Initially it is
1760 # empty and no node occupied a column. Empty columns are allocated on demand.
1761 # Free index for column being allocated is searched in following way.
1762 # 1. Start from desired column and look towards graph center (0 column).
1763 # 2. Start from center and look in both directions simultaneously.
1764 # Desired column is defaulted to 0. Fork node should set desired column for
1765 # children equal to its one. This prevents branch from jumping too far from
1766 # its fork.
1768 # Initialization is performed by reset_columns method. Column allocation is
1769 # implemented in alloc_column method. Initialization and main loop are in
1770 # recompute_grid method. The method also embeds row assignment algorithm by
1771 # implementation.
1773 # Actions for each node are follow.
1774 # 1. If the node was not assigned a column then it is assigned empty one.
1775 # 2. Allocate row.
1776 # 3. Allocate columns for children.
1777 # If a child have a column assigned then it should no be overridden. One of
1778 # children is assigned same column as the node. If the node is a fork then the
1779 # child is chosen in generation descent order. This is a heuristic and it only
1780 # affects resulting appearance of the graph. Other children are assigned empty
1781 # columns in same order. It is the heuristic too.
1782 # 4. If no child occupies column of the node then leave it.
1783 # It is possible in consequent situations.
1784 # 4.1 The node is a leaf.
1785 # 4.2 The node is a fork and all its children are already assigned side
1786 # column. It is possible if all the children are merges.
1787 # 4.3 Single node child is a merge that is already assigned a column.
1788 # 5. Propagate frontier with respect to this node.
1789 # Each frontier entry corresponding to column occupied by any node's child
1790 # must be gather than node row index. This meets first aim of the row
1791 # assignment algorithm.
1792 # Note that frontier of child that occupies same row was propagated during
1793 # step 2. Hence, it must be propagated for children on side columns.
1795 def reset_columns(self):
1796 # Some children of displayed commits might not be accounted in
1797 # 'commits' list. It is common case during loading of big graph.
1798 # But, they are assigned a column that must be reseted. Hence, use
1799 # depth-first traversal to reset all columns assigned.
1800 for node in self.commits:
1801 if node.column is None:
1802 continue
1803 stack = [node]
1804 while stack:
1805 node = stack.pop()
1806 node.column = None
1807 for child in node.children:
1808 if child.column is not None:
1809 stack.append(child)
1811 self.columns = {}
1812 self.max_column = 0
1813 self.min_column = 0
1815 def reset_rows(self):
1816 self.frontier = {}
1817 self.tagged_cells = set()
1819 def declare_column(self, column):
1820 if self.frontier:
1821 # Align new column frontier by frontier of nearest column. If all
1822 # columns were left then select maximum frontier value.
1823 if not self.columns:
1824 self.frontier[column] = max(list(self.frontier.values()))
1825 return
1826 # This is heuristic that mostly affects roots. Note that the
1827 # frontier values for fork children will be overridden in course of
1828 # propagate_frontier.
1829 for offset in itertools.count(1):
1830 for c in [column + offset, column - offset]:
1831 if c not in self.columns:
1832 # Column 'c' is not occupied.
1833 continue
1834 try:
1835 frontier = self.frontier[c]
1836 except KeyError:
1837 # Column 'c' was never allocated.
1838 continue
1840 frontier -= 1
1841 # The frontier of the column may be higher because of
1842 # tag overlapping prevention performed for previous head.
1843 try:
1844 if self.frontier[column] >= frontier:
1845 break
1846 except KeyError:
1847 pass
1849 self.frontier[column] = frontier
1850 break
1851 else:
1852 continue
1853 break
1854 else:
1855 # First commit must be assigned 0 row.
1856 self.frontier[column] = 0
1858 def alloc_column(self, column=0):
1859 columns = self.columns
1860 # First, look for free column by moving from desired column to graph
1861 # center (column 0).
1862 for c in range(column, 0, -1 if column > 0 else 1):
1863 if c not in columns:
1864 if c > self.max_column:
1865 self.max_column = c
1866 elif c < self.min_column:
1867 self.min_column = c
1868 break
1869 else:
1870 # If no free column was found between graph center and desired
1871 # column then look for free one by moving from center along both
1872 # directions simultaneously.
1873 for c in itertools.count(0):
1874 if c not in columns:
1875 if c > self.max_column:
1876 self.max_column = c
1877 break
1878 c = -c
1879 if c not in columns:
1880 if c < self.min_column:
1881 self.min_column = c
1882 break
1883 self.declare_column(c)
1884 columns[c] = 1
1885 return c
1887 def alloc_cell(self, column, tags):
1888 # Get empty cell from frontier.
1889 cell_row = self.frontier[column]
1891 if tags:
1892 # Prevent overlapping of tag with cells already allocated a row.
1893 if self.x_off > 0:
1894 can_overlap = list(range(column + 1, self.max_column + 1))
1895 else:
1896 can_overlap = list(range(column - 1, self.min_column - 1, -1))
1897 for c in can_overlap:
1898 frontier = self.frontier[c]
1899 if frontier > cell_row:
1900 cell_row = frontier
1902 # Avoid overlapping with tags of commits at cell_row.
1903 if self.x_off > 0:
1904 can_overlap = list(range(self.min_column, column))
1905 else:
1906 can_overlap = list(range(self.max_column, column, -1))
1907 for cell_row in itertools.count(cell_row):
1908 for c in can_overlap:
1909 if (c, cell_row) in self.tagged_cells:
1910 # Overlapping. Try next row.
1911 break
1912 else:
1913 # No overlapping was found.
1914 break
1915 # Note that all checks should be made for new cell_row value.
1917 if tags:
1918 self.tagged_cells.add((column, cell_row))
1920 # Propagate frontier.
1921 self.frontier[column] = cell_row + 1
1922 return cell_row
1924 def propagate_frontier(self, column, value):
1925 current = self.frontier[column]
1926 if current < value:
1927 self.frontier[column] = value
1929 def leave_column(self, column):
1930 count = self.columns[column]
1931 if count == 1:
1932 del self.columns[column]
1933 else:
1934 self.columns[column] = count - 1
1936 def recompute_grid(self):
1937 self.reset_columns()
1938 self.reset_rows()
1940 for node in sort_by_generation(list(self.commits)):
1941 if node.column is None:
1942 # Node is either root or its parent is not in items. The last
1943 # happens when tree loading is in progress. Allocate new
1944 # columns for such nodes.
1945 node.column = self.alloc_column()
1947 node.row = self.alloc_cell(node.column, node.tags)
1949 # Allocate columns for children which are still without one. Also
1950 # propagate frontier for children.
1951 if node.is_fork():
1952 sorted_children = sorted(node.children,
1953 key=lambda c: c.generation,
1954 reverse=True)
1955 citer = iter(sorted_children)
1956 for child in citer:
1957 if child.column is None:
1958 # Top most child occupies column of parent.
1959 child.column = node.column
1960 # Note that frontier is propagated in course of
1961 # alloc_cell.
1962 break
1963 else:
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)