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