1 from __future__
import division
, absolute_import
, unicode_literals
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
15 from ..models
import dag
16 from ..qtutils
import get
19 from .. import difftool
20 from .. import gitcmds
21 from .. import hotkeys
23 from .. import observable
24 from .. import qtcompat
25 from .. import qtutils
29 from . import completion
30 from . import createbranch
31 from . import createtag
34 from . import filelist
35 from . import standard
38 def git_dag(context
, args
=None, existing_view
=None, show
=True):
39 """Return a pre-populated git DAG widget."""
41 branch
= model
.currentbranch
42 # disambiguate between branch names and filenames by using '--'
43 branch_doubledash
= (branch
+ ' --') if branch
else ''
44 params
= dag
.DAG(branch_doubledash
, 1000)
45 params
.set_arguments(args
)
47 if existing_view
is None:
48 view
= GitDAG(context
, params
)
51 view
.set_params(params
)
59 class FocusRedirectProxy(object):
60 """Redirect actions from the main widget to child widgets"""
62 def __init__(self
, *widgets
):
63 """Provide proxied widgets; the default widget must be first"""
64 self
.widgets
= widgets
65 self
.default
= widgets
[0]
67 def __getattr__(self
, name
):
68 return lambda *args
, **kwargs
: self
._forward
_action
(name
, *args
, **kwargs
)
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
)
76 fn
= getattr(self
.default
, name
)
78 return fn(*args
, **kwargs
)
81 class ViewerMixin(object):
82 """Implementations must provide selected_items()"""
85 self
.context
= None # provided by implementation
88 self
.menu_actions
= None # provided by implementation
90 def selected_item(self
):
91 """Return the currently selected item"""
92 selected_items
= self
.selected_items()
93 if not selected_items
:
95 return selected_items
[0]
97 def selected_oid(self
):
98 item
= self
.selected_item()
102 result
= item
.commit
.oid
105 def selected_oids(self
):
106 return [i
.commit
for i
in self
.selected_items()]
108 def with_oid(self
, fn
):
109 oid
= self
.selected_oid()
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
)
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
)
126 def cherry_pick(self
):
127 context
= self
.context
128 self
.with_oid(lambda oid
: cmds
.do(cmds
.CherryPick
, context
, [oid
]))
131 context
= self
.context
132 self
.with_oid(lambda oid
: cmds
.do(cmds
.Revert
, context
, oid
))
134 def copy_to_clipboard(self
):
135 self
.with_oid(qtutils
.set_clipboard
)
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
))
142 def create_tag(self
):
143 context
= self
.context
144 self
.with_oid(lambda oid
: createtag
.create_tag(context
, ref
=oid
))
146 def create_tarball(self
):
147 context
= self
.context
148 self
.with_oid(lambda oid
: archive
.show_save_dialog(context
, oid
, parent
=self
))
151 context
= self
.context
153 lambda oid
: difftool
.diff_expression(
154 context
, self
, oid
+ '^!', hide_expr
=False, focus_tree
=True
158 def show_dir_diff(self
):
159 context
= self
.context
161 lambda oid
: cmds
.difftool_launch(
162 context
, left
=oid
, left_take_magic
=True, dir_diff
=True
166 def reset_mixed(self
):
167 context
= self
.context
168 self
.with_oid(lambda oid
: cmds
.do(cmds
.ResetMixed
, context
, ref
=oid
))
170 def reset_keep(self
):
171 context
= self
.context
172 self
.with_oid(lambda oid
: cmds
.do(cmds
.ResetKeep
, context
, ref
=oid
))
174 def reset_merge(self
):
175 context
= self
.context
176 self
.with_oid(lambda oid
: cmds
.do(cmds
.ResetMerge
, context
, ref
=oid
))
178 def reset_soft(self
):
179 context
= self
.context
180 self
.with_oid(lambda oid
: cmds
.do(cmds
.ResetSoft
, context
, ref
=oid
))
182 def reset_hard(self
):
183 context
= self
.context
184 self
.with_oid(lambda oid
: cmds
.do(cmds
.ResetHard
, context
, ref
=oid
))
186 def restore_worktree(self
):
187 context
= self
.context
188 self
.with_oid(lambda oid
: cmds
.do(cmds
.RestoreWorktree
, context
, ref
=oid
))
190 def checkout_detached(self
):
191 context
= self
.context
192 self
.with_oid(lambda oid
: cmds
.do(cmds
.Checkout
, context
, [oid
]))
194 def save_blob_dialog(self
):
195 context
= self
.context
196 self
.with_oid(lambda oid
: browse
.BrowseBranch
.browse(context
, oid
))
198 def update_menu_actions(self
, event
):
199 selected_items
= self
.selected_items()
200 item
= self
.itemAt(event
.pos())
202 self
.clicked
= commit
= None
204 self
.clicked
= commit
= item
.commit
206 has_single_selection
= len(selected_items
) == 1
207 has_selection
= bool(selected_items
)
209 commit
and has_single_selection
and commit
is not selected_items
[0].commit
213 self
.selected
= selected_items
[0].commit
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
)
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
)
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'])
246 menu
.addAction(self
.menu_actions
['create_branch'])
247 menu
.addAction(self
.menu_actions
['create_tag'])
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'])
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'])
264 menu
.addAction(self
.menu_actions
['save_blob'])
265 menu
.addAction(self
.menu_actions
['copy'])
266 menu
.exec_(self
.mapToGlobal(event
.pos()))
269 def set_icon(icon
, action
):
270 """"Set the icon for an action and return the action"""
275 def viewer_actions(widget
):
277 'diff_this_selected': set_icon(
280 widget
, N_('Diff this -> selected'), widget
.proxy
.diff_this_selected
283 'diff_selected_this': set_icon(
286 widget
, N_('Diff selected -> this'), widget
.proxy
.diff_selected_this
289 'create_branch': set_icon(
291 qtutils
.add_action(widget
, N_('Create Branch'), widget
.proxy
.create_branch
),
293 'create_patch': set_icon(
295 qtutils
.add_action(widget
, N_('Create Patch'), widget
.proxy
.create_patch
),
297 'create_tag': set_icon(
299 qtutils
.add_action(widget
, N_('Create Tag'), widget
.proxy
.create_tag
),
301 'create_tarball': set_icon(
304 widget
, N_('Save As Tarball/Zip...'), widget
.proxy
.create_tarball
307 'cherry_pick': set_icon(
308 icons
.style_dialog_apply(),
309 qtutils
.add_action(widget
, N_('Cherry Pick'), widget
.proxy
.cherry_pick
),
312 icons
.undo(), qtutils
.add_action(widget
, N_('Revert'), widget
.proxy
.revert
)
314 'diff_commit': set_icon(
317 widget
, N_('Launch Diff Tool'), widget
.proxy
.show_diff
, hotkeys
.DIFF
320 'diff_commit_all': set_icon(
324 N_('Launch Directory Diff Tool'),
325 widget
.proxy
.show_dir_diff
,
326 hotkeys
.DIFF_SECONDARY
,
329 'checkout_detached': qtutils
.add_action(
330 widget
, N_('Checkout Detached HEAD'), widget
.proxy
.checkout_detached
332 'reset_soft': set_icon(
333 icons
.style_dialog_reset(),
335 widget
, N_('Reset Branch (Soft)'), widget
.proxy
.reset_soft
338 'reset_mixed': set_icon(
339 icons
.style_dialog_reset(),
341 widget
, N_('Reset Branch and Stage (Mixed)'), widget
.proxy
.reset_mixed
344 'reset_keep': set_icon(
345 icons
.style_dialog_reset(),
348 N_('Restore Worktree and Reset All (Keep Unstaged Edits)'),
349 widget
.proxy
.reset_keep
,
352 'reset_merge': set_icon(
353 icons
.style_dialog_reset(),
356 N_('Restore Worktree and Reset All (Merge)'),
357 widget
.proxy
.reset_merge
,
360 'reset_hard': set_icon(
361 icons
.style_dialog_reset(),
364 N_('Restore Worktree and Reset All (Hard)'),
365 widget
.proxy
.reset_hard
,
368 'restore_worktree': set_icon(
371 widget
, N_('Restore Worktree'), widget
.proxy
.restore_worktree
374 'save_blob': set_icon(
377 widget
, N_('Grab File...'), widget
.proxy
.save_blob_dialog
385 widget
.proxy
.copy_to_clipboard
,
392 class CommitTreeWidgetItem(QtWidgets
.QTreeWidgetItem
):
393 def __init__(self
, commit
, parent
=None):
394 QtWidgets
.QTreeWidgetItem
.__init
__(self
, parent
)
396 self
.setText(0, commit
.summary
)
397 self
.setText(1, commit
.author
)
398 self
.setText(2, commit
.authdate
)
401 # pylint: disable=too-many-ancestors
402 class CommitTreeWidget(standard
.TreeWidget
, ViewerMixin
):
404 diff_commits
= Signal(object, object)
405 zoom_to_fit
= Signal()
407 def __init__(self
, context
, notifier
, parent
):
408 standard
.TreeWidget
.__init
__(self
, parent
)
409 ViewerMixin
.__init
__(self
)
411 self
.setSelectionMode(self
.ExtendedSelection
)
412 self
.setHeaderLabels([N_('Summary'), N_('Author'), N_('Date, Time')])
414 self
.context
= context
416 self
.menu_actions
= None
417 self
.notifier
= notifier
418 self
.selecting
= False
420 self
._adjust
_columns
= False
422 self
.action_up
= qtutils
.add_action(
423 self
, N_('Go Up'), self
.go_up
, hotkeys
.MOVE_UP
426 self
.action_down
= qtutils
.add_action(
427 self
, N_('Go Down'), self
.go_down
, hotkeys
.MOVE_DOWN
430 self
.zoom_to_fit_action
= qtutils
.add_action(
431 self
, N_('Zoom to Fit'), self
.zoom_to_fit
.emit
, hotkeys
.FIT
434 notifier
.add_observer(diff
.COMMITS_SELECTED
, self
.commits_selected
)
435 # pylint: disable=no-member
436 self
.itemSelectionChanged
.connect(self
.selection_changed
)
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.
443 state
['column_widths'] = self
.column_widths()
446 def apply_state(self
, state
):
447 """Apply the exported widget state"""
449 column_widths
= state
['column_widths']
450 except (KeyError, ValueError):
453 self
.set_column_widths(column_widths
)
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
460 self
._adjust
_columns
= True
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
469 two_thirds
= (width
* 2) // 3
470 one_sixth
= width
// 6
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
)
479 self
.goto(self
.itemAbove
)
482 self
.goto(self
.itemBelow
)
484 def goto(self
, finder
):
485 items
= self
.selected_items()
486 item
= items
[0] if items
else None
491 self
.select([found
.commit
.oid
])
493 def selected_commit_range(self
):
494 selected_items
= self
.selected_items()
495 if not selected_items
:
497 return selected_items
[-1].commit
.oid
, selected_items
[0].commit
.oid
499 def set_selecting(self
, selecting
):
500 self
.selecting
= selecting
502 def selection_changed(self
):
503 items
= self
.selected_items()
506 self
.set_selecting(True)
507 self
.notifier
.notify_observers(diff
.COMMITS_SELECTED
, [i
.commit
for i
in items
])
508 self
.set_selecting(False)
510 def commits_selected(self
, commits
):
513 with qtutils
.BlockSignals(self
):
514 self
.select([commit
.oid
for commit
in commits
])
516 def select(self
, oids
):
519 self
.clearSelection()
522 item
= self
.oidmap
[oid
]
525 self
.scrollToItem(item
)
526 item
.setSelected(True)
529 QtWidgets
.QTreeWidget
.clear(self
)
533 def add_commits(self
, commits
):
534 self
.commits
.extend(commits
)
536 for c
in reversed(commits
):
537 item
= CommitTreeWidgetItem(c
)
539 self
.oidmap
[c
.oid
] = item
541 self
.oidmap
[tag
] = item
542 self
.insertTopLevelItems(0, items
)
544 def create_patch(self
):
545 items
= self
.selectedItems()
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
)
554 def contextMenuEvent(self
, event
):
555 self
.context_menu_event(event
)
557 def mousePressEvent(self
, event
):
558 if event
.button() == Qt
.RightButton
:
561 QtWidgets
.QTreeWidget
.mousePressEvent(self
, event
)
564 class GitDAG(standard
.MainWindow
):
565 """The git-dag widget."""
569 def __init__(self
, context
, params
, parent
=None):
570 super(GitDAG
, self
).__init
__(parent
)
572 self
.setMinimumSize(420, 420)
574 # change when widgets are added/removed
575 self
.widget_version
= 2
576 self
.context
= context
578 self
.model
= context
.model
581 self
.commit_list
= []
583 self
.old_refs
= set()
586 self
.force_refresh
= False
589 self
.revtext
= completion
.GitLogLineEdit(context
)
590 self
.maxresults
= standard
.SpinBox()
592 self
.zoom_out
= qtutils
.create_action_button(
593 tooltip
=N_('Zoom Out'), icon
=icons
.zoom_out()
596 self
.zoom_in
= qtutils
.create_action_button(
597 tooltip
=N_('Zoom In'), icon
=icons
.zoom_in()
600 self
.zoom_to_fit
= qtutils
.create_action_button(
601 tooltip
=N_('Zoom to Fit'), icon
=icons
.zoom_fit_best()
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
)
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
)
616 self
.proxy
= FocusRedirectProxy(
617 self
.treewidget
, self
.graphview
, self
.filewidget
620 self
.viewer_actions
= actions
= viewer_actions(self
)
621 self
.treewidget
.menu_actions
= actions
622 self
.graphview
.menu_actions
= actions
624 self
.controls_layout
= qtutils
.hbox(
625 defs
.no_margin
, defs
.spacing
, self
.revtext
, self
.maxresults
628 self
.controls_widget
= QtWidgets
.QWidget()
629 self
.controls_widget
.setLayout(self
.controls_layout
)
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
)
636 self
.file_dock
= qtutils
.create_dock(N_('Files'), self
)
637 self
.file_dock
.setWidget(self
.filewidget
)
639 self
.diff_dock
= qtutils
.create_dock(N_('Diff'), self
)
640 self
.diff_dock
.setWidget(self
.diffwidget
)
642 self
.graph_controls_layout
= qtutils
.hbox(
651 self
.graph_controls_widget
= QtWidgets
.QWidget()
652 self
.graph_controls_widget
.setLayout(self
.graph_controls_layout
)
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
)
659 self
.lock_layout_action
= qtutils
.add_action_bool(
660 self
, N_('Lock Layout'), self
.set_lock_layout
, False
663 self
.refresh_action
= qtutils
.add_action(
664 self
, N_('Refresh'), self
.refresh
, hotkeys
.REFRESH
667 # Create the application menu
668 self
.menubar
= QtWidgets
.QMenuBar(self
)
669 self
.setMenuBar(self
.menubar
)
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
)
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
)
688 # Also re-loads dag.* from the saved state
689 self
.init_state(context
.settings
, self
.resize_to_desktop
)
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
)
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
)
700 # pylint: disable=no-member
701 self
.maxresults
.editingFinished
.connect(self
.display
)
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
)
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
)
713 qtutils
.add_action(self
, 'Focus', self
.focus_input
, hotkeys
.FOCUS
)
714 qtutils
.add_close_action(self
)
716 self
.set_params(params
)
718 def set_params(self
, params
):
719 context
= self
.context
722 # Update fields affected by model
723 self
.revtext
.setText(params
.ref
)
724 self
.maxresults
.setValue(params
.count
)
725 self
.update_window_title()
727 if self
.thread
is not None:
730 self
.thread
= ReaderThread(context
, params
, self
)
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
)
738 def focus_input(self
):
739 self
.revtext
.setFocus()
741 def focus_tree(self
):
742 self
.treewidget
.setFocus()
744 def text_changed(self
, txt
):
745 self
.params
.ref
= txt
746 self
.update_window_title()
748 def update_window_title(self
):
749 project
= self
.model
.project
752 N_('%(project)s: %(ref)s - DAG')
753 % dict(project
=project
, ref
=self
.params
.ref
)
756 self
.setWindowTitle(project
+ N_(' - DAG'))
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()
764 def apply_state(self
, state
):
765 result
= standard
.MainWindow
.apply_state(self
, state
)
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
773 self
.params
.set_count(count
)
774 self
.lock_layout_action
.setChecked(state
.get('lock_layout', False))
777 log_state
= state
['log']
778 except (KeyError, ValueError):
781 self
.treewidget
.apply_state(log_state
)
785 def model_updated(self
):
787 self
.update_window_title()
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
797 """Update the view when the Git refs change"""
798 ref
= get(self
.revtext
)
799 count
= get(self
.maxresults
)
800 context
= self
.context
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.
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.
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
)
819 or count
!= self
.old_count
820 or oids
!= self
.old_oids
821 or refs
!= self
.old_refs
825 self
.params
.set_ref(ref
)
826 self
.params
.set_count(count
)
830 self
.old_count
= count
833 def commits_selected(self
, commits
):
835 self
.selection
= commits
839 self
.commit_list
= []
840 self
.graphview
.clear()
841 self
.treewidget
.clear()
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
)
853 def thread_begin(self
):
856 def thread_end(self
):
857 self
.restore_selection()
859 def thread_status(self
, successful
):
860 self
.revtext
.hint
.set_error(not successful
)
862 def restore_selection(self
):
863 selection
= self
.selection
865 commit_obj
= self
.commit_list
[-1]
867 # No commits, exist, early-out
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]
873 # The old selection exists in the new state
874 self
.notifier
.notify_observers(diff
.COMMITS_SELECTED
, new_commits
)
876 # The old selection is now empty. Select the top-most commit
877 self
.notifier
.notify_observers(diff
.COMMITS_SELECTED
, [commit_obj
])
879 self
.graphview
.set_initial_view()
881 def diff_commits(self
, a
, b
):
882 paths
= self
.params
.paths()
884 cmds
.difftool_launch(self
.context
, left
=a
, right
=b
, paths
=paths
)
886 difftool
.diff_commits(self
.context
, self
, a
, b
)
889 def closeEvent(self
, event
):
890 self
.revtext
.close_popup()
892 standard
.MainWindow
.closeEvent(self
, event
)
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
)
901 def difftool_selected(self
, files
):
902 bottom
, top
= self
.treewidget
.selected_commit_range()
905 cmds
.difftool_launch(
906 self
.context
, left
=bottom
, left_take_parent
=True, right
=top
, paths
=files
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
)
916 class ReaderThread(QtCore
.QThread
):
920 status
= Signal(object)
922 def __init__(self
, context
, params
, parent
):
923 QtCore
.QThread
.__init
__(self
, parent
)
924 self
.context
= context
928 self
._mutex
= QtCore
.QMutex()
929 self
._condition
= QtCore
.QWaitCondition()
932 context
= self
.context
933 repo
= dag
.RepoReader(context
, self
.params
)
940 self
._condition
.wait(self
._mutex
)
946 if len(commits
) >= 512:
947 self
.add
.emit(commits
)
950 self
.status
.emit(repo
.returncode
== 0)
952 self
.add
.emit(commits
)
958 QtCore
.QThread
.start(self
)
969 self
._condition
.wakeOne()
982 font
= cls
._label
_font
984 font
= cls
._label
_font
= QtWidgets
.QApplication
.font()
989 class Edge(QtWidgets
.QGraphicsItem
):
990 item_type
= QtWidgets
.QGraphicsItem
.UserType
+ 1
992 def __init__(self
, source
, dest
):
994 QtWidgets
.QGraphicsItem
.__init
__(self
)
996 self
.setAcceptedMouseButtons(Qt
.NoButton
)
999 self
.commit
= source
.commit
1002 self
.recompute_bound()
1004 self
.path_valid
= False
1006 # Choose a new color for new branch edges
1007 if self
.source
.x() < self
.dest
.x():
1008 color
= EdgeColor
.cycle()
1010 elif self
.source
.x() != self
.dest
.x():
1011 color
= EdgeColor
.current()
1014 color
= EdgeColor
.current()
1017 self
.pen
= QtGui
.QPen(color
, 4.0, line
, Qt
.SquareCap
, Qt
.RoundJoin
)
1019 def recompute_bound(self
):
1020 dest_pt
= Commit
.item_bbox
.center()
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
)
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()
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.
1043 return self
.item_type
1045 def boundingRect(self
):
1048 def recompute_path(self
):
1049 QRectF
= QtCore
.QRectF
1050 QPointF
= QtCore
.QPointF
1053 connector_length
= 5
1055 path
= QtGui
.QPainterPath()
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())
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
)
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
)
1071 start_angle_arc1
= 180
1072 span_angle_arc1
= 90
1073 start_angle_arc2
= 90
1074 span_angle_arc2
= -90
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())
1082 span_angle_arc1
= 90
1086 path
.arcTo(QRectF(point2
, point3
), start_angle_arc1
, span_angle_arc1
)
1088 path
.arcTo(QRectF(point6
, point5
), start_angle_arc2
, span_angle_arc2
)
1092 self
.path_valid
= True
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
)
1101 class EdgeColor(object):
1102 """An edge color factory"""
1104 current_color_index
= 0
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
),
1126 cls
.current_color_index
+= 1
1127 cls
.current_color_index
%= len(cls
.colors
)
1128 color
= cls
.colors
[cls
.current_color_index
]
1134 return cls
.colors
[cls
.current_color_index
]
1138 cls
.current_color_index
= 0
1141 class Commit(QtWidgets
.QGraphicsItem
):
1142 item_type
= QtWidgets
.QGraphicsItem
.UserType
+ 2
1143 commit_radius
= 12.0
1146 item_shape
= QtGui
.QPainterPath()
1148 commit_radius
/ -2.0, commit_radius
/ -2.0, commit_radius
, commit_radius
1150 item_bbox
= item_shape
.boundingRect()
1152 inner_rect
= QtGui
.QPainterPath()
1154 commit_radius
/ -2.0 + 2.0,
1155 commit_radius
/ -2.0 + 2.0,
1156 commit_radius
- 4.0,
1157 commit_radius
- 4.0,
1159 inner_rect
= inner_rect
.boundingRect()
1161 commit_color
= QtGui
.QColor(Qt
.white
)
1162 outline_color
= commit_color
.darker()
1163 merge_color
= QtGui
.QColor(Qt
.lightGray
)
1165 commit_selected_color
= QtGui
.QColor(Qt
.green
)
1166 selected_outline_color
= commit_selected_color
.darker()
1168 commit_pen
= QtGui
.QPen()
1169 commit_pen
.setWidth(1)
1170 commit_pen
.setColor(outline_color
)
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
,
1183 QtWidgets
.QGraphicsItem
.__init
__(self
)
1185 self
.commit
= commit
1186 self
.notifier
= notifier
1187 self
.selected
= False
1190 self
.setFlag(selectable
)
1191 self
.setCursor(cursor
)
1192 self
.setToolTip(commit
.oid
[:12] + ': ' + commit
.summary
)
1195 self
.label
= label
= Label(commit
)
1196 label
.setParentItem(self
)
1197 label
.setPos(xpos
+ 1, -self
.commit_radius
/ 2.0)
1201 if len(commit
.parents
) > 1:
1202 self
.brush
= cached_merge_color
1204 self
.brush
= cached_commit_color
1206 self
.pressed
= False
1207 self
.dragged
= False
1211 def blockSignals(self
, blocked
):
1212 self
.notifier
.notification_enabled
= not blocked
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)
1223 # Cache the pen for use in paint()
1225 self
.brush
= self
.commit_selected_color
1226 color
= self
.selected_outline_color
1228 if len(self
.commit
.parents
) > 1:
1229 self
.brush
= self
.merge_color
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
1238 return QtWidgets
.QGraphicsItem
.itemChange(self
, change
, value
)
1241 return self
.item_type
1243 def boundingRect(self
):
1244 return self
.item_bbox
1247 return self
.item_shape
1249 def paint(self
, painter
, option
, _widget
):
1251 # Do not draw outside the exposed rect
1252 painter
.setClipRect(option
.exposedRect
)
1255 painter
.setPen(self
.commit_pen
)
1256 painter
.setBrush(self
.brush
)
1257 painter
.drawEllipse(self
.inner_rect
)
1259 def mousePressEvent(self
, event
):
1260 QtWidgets
.QGraphicsItem
.mousePressEvent(self
, event
)
1262 self
.selected
= self
.isSelected()
1264 def mouseMoveEvent(self
, event
):
1267 QtWidgets
.QGraphicsItem
.mouseMoveEvent(self
, event
)
1269 def mouseReleaseEvent(self
, event
):
1270 QtWidgets
.QGraphicsItem
.mouseReleaseEvent(self
, event
)
1271 if not self
.dragged
and self
.selected
and event
.button() == Qt
.LeftButton
:
1273 self
.pressed
= False
1274 self
.dragged
= False
1277 class Label(QtWidgets
.QGraphicsItem
):
1279 item_type
= QtWidgets
.QGraphicsItem
.UserType
+ 3
1281 head_color
= QtGui
.QColor(Qt
.green
)
1282 other_color
= QtGui
.QColor(Qt
.white
)
1283 remote_color
= QtGui
.QColor(Qt
.yellow
)
1285 head_pen
= QtGui
.QPen()
1286 head_pen
.setColor(head_color
.darker().darker())
1287 head_pen
.setWidth(1)
1289 text_pen
= QtGui
.QPen()
1290 text_pen
.setColor(QtGui
.QColor(Qt
.darkGray
))
1291 text_pen
.setWidth(1)
1294 head_color
.setAlpha(alpha
)
1295 other_color
.setAlpha(alpha
)
1296 remote_color
.setAlpha(alpha
)
1302 def __init__(self
, commit
):
1303 QtWidgets
.QGraphicsItem
.__init
__(self
)
1305 self
.commit
= commit
1308 return self
.item_type
1310 def boundingRect(self
, cache
=Cache
):
1311 QPainterPath
= QtGui
.QPainterPath
1312 QRectF
= QtCore
.QRectF
1317 spacing
= self
.item_spacing
1318 border
= self
.border
+ self
.text_offset
# text offset=1 in paint()
1320 font
= cache
.label_font()
1321 item_shape
= QPainterPath()
1323 base_rect
= QRectF(0, 0, width
, height
)
1324 base_rect
= base_rect
.adjusted(-border
, -border
, border
, border
)
1325 item_shape
.addRect(base_rect
)
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
1335 return item_shape
.boundingRect()
1337 def paint(self
, painter
, _option
, _widget
, cache
=Cache
):
1338 # Draw tags and branches
1339 font
= cache
.label_font()
1340 painter
.setFont(font
)
1343 border
= self
.border
1344 offset
= self
.text_offset
1345 spacing
= self
.item_spacing
1346 QRectF
= QtCore
.QRectF
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
)
1356 for tag
in self
.commit
.tags
:
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
)
1373 painter
.setPen(self
.text_pen
)
1374 painter
.setBrush(self
.other_color
)
1376 text_rect
= painter
.boundingRect(
1377 QRectF(current_width
, 0, 0, 0), Qt
.TextSingleLine
, tag
1379 box_rect
= text_rect
.adjusted(-offset
, -offset
, offset
, offset
)
1381 painter
.drawRoundedRect(box_rect
, border
, border
)
1382 painter
.drawText(text_rect
, Qt
.TextSingleLine
, tag
)
1383 current_width
+= text_rect
.width() + spacing
1386 # pylint: disable=too-many-ancestors
1387 class GraphView(QtWidgets
.QGraphicsView
, ViewerMixin
):
1389 diff_commits
= Signal(object, object)
1391 x_adjust
= int(Commit
.commit_radius
* 4 / 3)
1392 y_adjust
= int(Commit
.commit_radius
* 4 / 3)
1397 def __init__(self
, context
, notifier
, parent
):
1398 QtWidgets
.QGraphicsView
.__init
__(self
, parent
)
1399 ViewerMixin
.__init
__(self
)
1401 highlight
= self
.palette().color(QtGui
.QPalette
.Highlight
)
1402 Commit
.commit_selected_color
= highlight
1403 Commit
.selected_outline_color
= highlight
.darker()
1405 self
.context
= context
1407 self
.selection_list
= []
1408 self
.menu_actions
= None
1409 self
.notifier
= notifier
1412 self
.mouse_start
= [0, 0]
1413 self
.saved_matrix
= self
.transform()
1417 self
.tagged_cells
= set()
1421 self
.x_offsets
= collections
.defaultdict(lambda: self
.x_min
)
1423 self
.is_panning
= False
1424 self
.pressed
= False
1425 self
.selecting
= False
1426 self
.last_mouse
= [0, 0]
1428 self
.setDragMode(self
.RubberBandDrag
)
1430 scene
= QtWidgets
.QGraphicsScene(self
)
1431 scene
.setItemIndexMethod(QtWidgets
.QGraphicsScene
.NoIndex
)
1432 self
.setScene(scene
)
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
))
1446 hotkeys
.ZOOM_IN_SECONDARY
,
1449 qtutils
.add_action(self
, N_('Zoom Out'), self
.zoom_out
, hotkeys
.ZOOM_OUT
)
1451 qtutils
.add_action(self
, N_('Zoom to Fit'), self
.zoom_to_fit
, hotkeys
.FIT
)
1454 self
, N_('Select Parent'), self
._select
_parent
, hotkeys
.MOVE_DOWN_TERTIARY
1459 N_('Select Oldest Parent'),
1460 self
._select
_oldest
_parent
,
1465 self
, N_('Select Child'), self
._select
_child
, hotkeys
.MOVE_UP_TERTIARY
1469 self
, N_('Select Newest Child'), self
._select
_newest
_child
, hotkeys
.MOVE_UP
1472 notifier
.add_observer(diff
.COMMITS_SELECTED
, self
.commits_selected
)
1476 self
.scene().clear()
1477 self
.selection_list
= []
1479 self
.x_offsets
.clear()
1483 # ViewerMixin interface
1484 def selected_items(self
):
1485 """Return the currently selected items"""
1486 return self
.scene().selectedItems()
1489 self
.scale_view(1.5)
1492 self
.scale_view(1.0 / 1.5)
1494 def commits_selected(self
, commits
):
1497 self
.select([commit
.oid
for commit
in commits
])
1499 def select(self
, oids
):
1500 """Select the item for the oids"""
1501 self
.scene().clearSelection()
1504 item
= self
.items
[oid
]
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
)
1513 def _get_item_by_generation(self
, commits
, criteria_fn
):
1514 """Return the item for the commit matching criteria"""
1518 for commit
in commits
:
1519 if generation
is None or criteria_fn(generation
, commit
.generation
):
1521 generation
= commit
.generation
1523 return self
.items
[oid
]
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
)
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
)
1535 def create_patch(self
):
1536 items
= self
.selected_items()
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
)
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:
1550 parent_item
= self
._newest
_item
(selected_item
.commit
.parents
)
1551 if parent_item
is None:
1553 selected_item
.setSelected(False)
1554 parent_item
.setSelected(True)
1555 self
.ensureVisible(parent_item
.mapRectToScene(parent_item
.boundingRect()))
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:
1562 parent_item
= self
._oldest
_item
(selected_item
.commit
.parents
)
1563 if parent_item
is None:
1565 selected_item
.setSelected(False)
1566 parent_item
.setSelected(True)
1567 scene_rect
= parent_item
.mapRectToScene(parent_item
.boundingRect())
1568 self
.ensureVisible(scene_rect
)
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:
1575 child_item
= self
._oldest
_item
(selected_item
.commit
.children
)
1576 if child_item
is None:
1578 selected_item
.setSelected(False)
1579 child_item
.setSelected(True)
1580 scene_rect
= child_item
.mapRectToScene(child_item
.boundingRect())
1581 self
.ensureVisible(scene_rect
)
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:
1588 if len(selected_item
.commit
.children
) > 1:
1589 children
= selected_item
.commit
.children
[1:]
1591 children
= selected_item
.commit
.children
1592 child_item
= self
._newest
_item
(children
)
1593 if child_item
is None:
1595 selected_item
.setSelected(False)
1596 child_item
.setSelected(True)
1597 scene_rect
= child_item
.mapRectToScene(child_item
.boundingRect())
1598 self
.ensureVisible(scene_rect
)
1600 def set_initial_view(self
):
1602 selected
= self
.selected_items()
1604 items
.extend(selected
)
1606 if not selected
and self
.commits
:
1607 commit
= self
.commits
[-1]
1608 items
.append(self
.items
[commit
.oid
])
1610 self
.setSceneRect(self
.scene().itemsBoundingRect())
1611 self
.fit_view_to_items(items
)
1613 def zoom_to_fit(self
):
1614 """Fit selected items into the viewport"""
1616 items
= self
.selected_items()
1617 self
.fit_view_to_items(items
)
1619 def fit_view_to_items(self
, items
):
1621 rect
= self
.scene().itemsBoundingRect()
1623 x_min
= y_min
= maxsize
1624 x_max
= y_max
= -maxsize
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
)
1635 rect
= QtCore
.QRectF(x_min
, y_min
, abs(x_max
- x_min
), abs(y_max
- y_min
))
1637 x_adjust
= abs(GraphView
.x_adjust
)
1638 y_adjust
= abs(GraphView
.y_adjust
)
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
)
1648 self
.fitInView(rect
, Qt
.KeepAspectRatio
)
1649 self
.scene().invalidate()
1651 def save_selection(self
, event
):
1652 if event
.button() != Qt
.LeftButton
:
1654 elif Qt
.ShiftModifier
!= event
.modifiers():
1656 self
.selection_list
= self
.selected_items()
1658 def restore_selection(self
, event
):
1659 if Qt
.ShiftModifier
!= event
.modifiers():
1661 for item
in self
.selection_list
:
1662 item
.setSelected(True)
1664 def handle_event(self
, event_handler
, event
):
1665 self
.save_selection(event
)
1666 event_handler(self
, event
)
1667 self
.restore_selection(event
)
1670 def set_selecting(self
, selecting
):
1671 self
.selecting
= selecting
1673 def pan(self
, event
):
1675 dx
= pos
.x() - self
.mouse_start
[0]
1676 dy
= pos
.y() - self
.mouse_start
[1]
1678 if dx
== 0 and dy
== 0:
1681 rect
= QtCore
.QRect(0, 0, abs(dx
), abs(dy
))
1682 delta
= self
.mapToScene(rect
).boundingRect()
1692 matrix
= self
.transform()
1694 matrix
*= self
.saved_matrix
1695 matrix
.translate(tx
, ty
)
1697 self
.setTransformationAnchor(QtWidgets
.QGraphicsView
.NoAnchor
)
1698 self
.setTransform(matrix
)
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)
1707 .mapRect(QtCore
.QRectF(0.0, 0.0, 1.0, 1.0))
1710 if factor
< 0.014 or factor
> 42.0:
1712 self
.setTransformationAnchor(QtWidgets
.QGraphicsView
.AnchorUnderMouse
)
1714 self
.scale(zoom
, zoom
)
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
)
1722 matrix
= self
.transform().translate(tx
* factor
, ty
* factor
)
1723 self
.setTransformationAnchor(QtWidgets
.QGraphicsView
.NoAnchor
)
1724 self
.setTransform(matrix
)
1726 def scale_view(self
, scale
):
1729 .scale(scale
, scale
)
1730 .mapRect(QtCore
.QRectF(0, 0, 1, 1))
1733 if factor
< 0.07 or factor
> 100.0:
1737 adjust_scrollbars
= True
1738 scrollbar
= self
.verticalScrollBar()
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
1747 scrolloffset
= distance
/ range_
1749 adjust_scrollbars
= False
1751 self
.setTransformationAnchor(QtWidgets
.QGraphicsView
.NoAnchor
)
1752 self
.scale(scale
, scale
)
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
)
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
1773 self
.layout_commits()
1776 def link(self
, commits
):
1777 """Create edges linking commits with their parents"""
1778 scene
= self
.scene()
1779 for commit
in commits
:
1781 commit_item
= self
.items
[commit
.oid
]
1783 # TODO - Handle truncated history viewing
1785 for parent
in reversed(commit
.parents
):
1787 parent_item
= self
.items
[parent
.oid
]
1789 # TODO - Handle truncated history viewing
1792 edge
= parent_item
.edges
[commit
.oid
]
1794 edge
= Edge(parent_item
, commit_item
)
1797 parent_item
.edges
[commit
.oid
] = edge
1798 commit_item
.edges
[parent
.oid
] = edge
1801 def layout_commits(self
):
1802 positions
= self
.position_nodes()
1804 # Each edge is accounted in two commits. Hence, accumulate invalid
1805 # edges to prevent double edge invalidation.
1806 invalid_edges
= set()
1808 for oid
, (x
, y
) in positions
.items():
1809 item
= self
.items
[oid
]
1815 for edge
in item
.edges
.values():
1816 invalid_edges
.add(edge
)
1818 for edge
in invalid_edges
:
1819 edge
.commits_were_invalidated()
1821 # Commit node layout technique
1823 # Nodes are aligned by a mesh. Columns and rows are distributed using
1824 # algorithms described below.
1826 # Row assignment algorithm
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.
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.
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.
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.
1848 # Column assignment algorithm
1850 # The algorithm traverses nodes in generation ascend order. This guarantees
1851 # that a node will be visited after all its parents.
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
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
1867 # Actions for each node are follow.
1868 # 1. If the node was not assigned a column then it is assigned empty one.
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.
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:
1901 for child
in node
.children
:
1902 if child
.column
is not None:
1909 def reset_rows(self
):
1911 self
.tagged_cells
= set()
1913 def declare_column(self
, column
):
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()))
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.
1929 frontier
= self
.frontier
[c
]
1931 # Column 'c' was never allocated.
1935 # The frontier of the column may be higher because of
1936 # tag overlapping prevention performed for previous head.
1938 if self
.frontier
[column
] >= frontier
:
1943 self
.frontier
[column
] = frontier
1949 # First commit must be assigned 0 row.
1950 self
.frontier
[column
] = 0
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
:
1960 elif c
< self
.min_column
:
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
:
1973 if c
not in columns
:
1974 if c
< self
.min_column
:
1977 self
.declare_column(c
)
1981 def alloc_cell(self
, column
, tags
):
1982 # Get empty cell from frontier.
1983 cell_row
= self
.frontier
[column
]
1986 # Prevent overlapping of tag with cells already allocated a row.
1988 can_overlap
= list(range(column
+ 1, self
.max_column
+ 1))
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
:
1996 # Avoid overlapping with tags of commits at cell_row.
1998 can_overlap
= list(range(self
.min_column
, column
))
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.
2007 # No overlapping was found.
2009 # Note that all checks should be made for new cell_row value.
2012 self
.tagged_cells
.add((column
, cell_row
))
2014 # Propagate frontier.
2015 self
.frontier
[column
] = cell_row
+ 1
2018 def propagate_frontier(self
, column
, value
):
2019 current
= self
.frontier
[column
]
2021 self
.frontier
[column
] = value
2023 def leave_column(self
, column
):
2024 count
= self
.columns
[column
]
2026 del self
.columns
[column
]
2028 self
.columns
[column
] = count
- 1
2030 def recompute_grid(self
):
2031 self
.reset_columns()
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()
2041 node
.row
= self
.alloc_cell(node
.column
, node
.tags
)
2043 # Allocate columns for children which are still without one. Also
2044 # propagate frontier for children.
2046 sorted_children
= sorted(
2047 node
.children
, key
=lambda c
: c
.generation
, reverse
=True
2049 citer
= iter(sorted_children
)
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
2057 self
.propagate_frontier(child
.column
, node
.row
+ 1)
2059 # No child occupies same column.
2060 self
.leave_column(node
.column
)
2061 # Note that the loop below will pass no iteration.
2063 # Rest children are allocated new column.
2065 if child
.column
is None:
2066 child
.column
= self
.alloc_column(node
.column
)
2067 self
.propagate_frontier(child
.column
, node
.row
+ 1)
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
2076 self
.leave_column(node
.column
)
2077 # But frontier must be propagated with respect to this
2079 self
.propagate_frontier(child
.column
, node
.row
+ 1)
2081 # This is a leaf node.
2082 self
.leave_column(node
.column
)
2084 def position_nodes(self
):
2085 self
.recompute_grid()
2087 x_start
= self
.x_start
2094 for node
in self
.commits
:
2095 x_pos
= x_start
+ node
.column
* x_off
2096 y_pos
= y_off
+ node
.row
* y_off
2098 positions
[node
.oid
] = (x_pos
, y_pos
)
2099 x_min
= min(x_min
, x_pos
)
2106 def contextMenuEvent(self
, event
):
2107 self
.context_menu_event(event
)
2109 def mousePressEvent(self
, event
):
2110 if event
.button() == Qt
.MidButton
:
2112 self
.mouse_start
= [pos
.x(), pos
.y()]
2113 self
.saved_matrix
= self
.transform()
2114 self
.is_panning
= True
2116 if event
.button() == Qt
.RightButton
:
2119 if event
.button() == Qt
.LeftButton
:
2121 self
.handle_event(QtWidgets
.QGraphicsView
.mousePressEvent
, event
)
2123 def mouseMoveEvent(self
, event
):
2124 pos
= self
.mapToScene(event
.pos())
2128 self
.last_mouse
[0] = pos
.x()
2129 self
.last_mouse
[1] = pos
.y()
2130 self
.handle_event(QtWidgets
.QGraphicsView
.mouseMoveEvent
, event
)
2132 self
.viewport().repaint()
2134 def mouseReleaseEvent(self
, event
):
2135 self
.pressed
= False
2136 if event
.button() == Qt
.MidButton
:
2137 self
.is_panning
= False
2139 self
.handle_event(QtWidgets
.QGraphicsView
.mouseReleaseEvent
, event
)
2140 self
.selection_list
= []
2141 self
.viewport().repaint()
2143 def wheelEvent(self
, event
):
2144 """Handle Qt mouse wheel events."""
2145 if event
.modifiers() & Qt
.ControlModifier
:
2146 self
.wheel_zoom(event
)
2148 self
.wheel_pan(event
)
2150 def fitInView(self
, rect
, flags
=Qt
.IgnoreAspectRatio
):
2151 """Override fitInView to remove unwanted margins
2153 https://bugreports.qt.io/browse/QTBUG-42331 - based on QT sources
2156 if self
.scene() is None or rect
.isNull():
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())
2172 def sort_by_generation(commits
):
2173 if len(commits
) < 2:
2175 commits
.sort(key
=lambda x
: x
.generation
)
2181 # oid -- Git objects IDs (i.e. SHA-1 IDs)
2182 # ref -- Git references that resolve to a commit-ish (HEAD, branches, tags)