1 from __future__
import absolute_import
, division
, print_function
, 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 ..models
import main
17 from ..qtutils
import get
20 from .. import difftool
21 from .. import gitcmds
22 from .. import guicmds
23 from .. import hotkeys
25 from .. import qtcompat
26 from .. import qtutils
30 from . import completion
31 from . import createbranch
32 from . import createtag
35 from . import filelist
36 from . import standard
39 def git_dag(context
, args
=None, existing_view
=None, show
=True):
40 """Return a pre-populated git DAG widget."""
42 branch
= model
.currentbranch
43 # disambiguate between branch names and filenames by using '--'
44 branch_doubledash
= (branch
+ ' --') if branch
else ''
45 params
= dag
.DAG(branch_doubledash
, 1000)
46 params
.set_arguments(args
)
48 if existing_view
is None:
49 view
= GitDAG(context
, params
)
52 view
.set_params(params
)
60 class FocusRedirectProxy(object):
61 """Redirect actions from the main widget to child widgets"""
63 def __init__(self
, *widgets
):
64 """Provide proxied widgets; the default widget must be first"""
65 self
.widgets
= widgets
66 self
.default
= widgets
[0]
68 def __getattr__(self
, name
):
69 return lambda *args
, **kwargs
: self
._forward
_action
(name
, *args
, **kwargs
)
71 def _forward_action(self
, name
, *args
, **kwargs
):
72 """Forward the captured action to the focused or default widget"""
73 widget
= QtWidgets
.QApplication
.focusWidget()
74 if widget
in self
.widgets
and hasattr(widget
, name
):
75 fn
= getattr(widget
, name
)
77 fn
= getattr(self
.default
, name
)
79 return fn(*args
, **kwargs
)
82 class ViewerMixin(object):
83 """Implementations must provide selected_items()"""
86 self
.context
= None # provided by implementation
89 self
.menu_actions
= None # provided by implementation
91 def selected_item(self
):
92 """Return the currently selected item"""
93 selected_items
= self
.selected_items()
94 if not selected_items
:
96 return selected_items
[0]
98 def selected_oid(self
):
99 """Return the currently selected commit object ID"""
100 item
= self
.selected_item()
104 result
= item
.commit
.oid
107 def selected_oids(self
):
108 """Return the currently selected comit object IDs"""
109 return [i
.commit
for i
in self
.selected_items()]
111 def clicked_oid(self
):
112 """Return the clicked or selected commit object ID"""
114 return self
.clicked
.oid
115 return self
.selected_oid()
117 def with_oid(self
, fn
):
118 """Run an operation with a commit object ID"""
119 oid
= self
.clicked_oid()
126 def with_selected_oid(self
, fn
):
127 """Run an operation with a commit object ID"""
128 oid
= self
.selected_oid()
135 def diff_selected_this(self
):
136 """Diff the selected commit against the clicked commit"""
137 clicked_oid
= self
.clicked
.oid
138 selected_oid
= self
.selected
.oid
139 self
.diff_commits
.emit(selected_oid
, clicked_oid
)
141 def diff_this_selected(self
):
142 """Diff the clicked commit against the selected commit"""
143 clicked_oid
= self
.clicked
.oid
144 selected_oid
= self
.selected
.oid
145 self
.diff_commits
.emit(clicked_oid
, selected_oid
)
147 def cherry_pick(self
):
148 """Cherry-pick a commit using git cherry-pick"""
149 context
= self
.context
150 self
.with_oid(lambda oid
: cmds
.do(cmds
.CherryPick
, context
, [oid
]))
153 """Revert a commit using git revert"""
154 context
= self
.context
155 self
.with_oid(lambda oid
: cmds
.do(cmds
.Revert
, context
, oid
))
157 def copy_to_clipboard(self
):
158 """Copy the current commit object ID to the clipboard"""
159 self
.with_oid(qtutils
.set_clipboard
)
161 def checkout_branch(self
):
162 """Checkout the clicked/selected branch"""
164 clicked
= self
.clicked
165 selected
= self
.selected_item()
167 branches
.extend(clicked
.branches
)
169 branches
.extend(selected
.commit
.branches
)
172 guicmds
.checkout_branch(self
.context
, default
=branches
[0])
174 def create_branch(self
):
175 """Create a branch at the selected commit"""
176 context
= self
.context
177 create_new_branch
= partial(createbranch
.create_new_branch
, context
)
178 self
.with_oid(lambda oid
: create_new_branch(revision
=oid
))
180 def create_tag(self
):
181 """Create a tag at the selected commit"""
182 context
= self
.context
183 self
.with_oid(lambda oid
: createtag
.create_tag(context
, ref
=oid
))
185 def create_tarball(self
):
186 """Create a tarball from the selected commit"""
187 context
= self
.context
188 self
.with_oid(lambda oid
: archive
.show_save_dialog(context
, oid
, parent
=self
))
191 """Show the diff for the selected commit"""
192 context
= self
.context
194 lambda oid
: difftool
.diff_expression(
195 context
, self
, oid
+ '^!', hide_expr
=False, focus_tree
=True
199 def show_dir_diff(self
):
200 """Show a full directory diff for the selected commit"""
201 context
= self
.context
203 lambda oid
: cmds
.difftool_launch(
204 context
, left
=oid
, left_take_magic
=True, dir_diff
=True
208 def rebase_to_commit(self
):
209 """Rebase the current branch to the selected commit"""
210 context
= self
.context
211 self
.with_oid(lambda oid
: cmds
.do(cmds
.Rebase
, context
, upstream
=oid
))
213 def reset_mixed(self
):
214 """Reset the repository using git reset --mixed"""
215 context
= self
.context
216 self
.with_oid(lambda oid
: cmds
.do(cmds
.ResetMixed
, context
, ref
=oid
))
218 def reset_keep(self
):
219 """Reset the repository using git reset --keep"""
220 context
= self
.context
221 self
.with_oid(lambda oid
: cmds
.do(cmds
.ResetKeep
, context
, ref
=oid
))
223 def reset_merge(self
):
224 """Reset the repository using git reset --merge"""
225 context
= self
.context
226 self
.with_oid(lambda oid
: cmds
.do(cmds
.ResetMerge
, context
, ref
=oid
))
228 def reset_soft(self
):
229 """Reset the repository using git reset --soft"""
230 context
= self
.context
231 self
.with_oid(lambda oid
: cmds
.do(cmds
.ResetSoft
, context
, ref
=oid
))
233 def reset_hard(self
):
234 """Reset the repository using git reset --hard"""
235 context
= self
.context
236 self
.with_oid(lambda oid
: cmds
.do(cmds
.ResetHard
, context
, ref
=oid
))
238 def restore_worktree(self
):
239 """Reset the worktree contents from the selected commit"""
240 context
= self
.context
241 self
.with_oid(lambda oid
: cmds
.do(cmds
.RestoreWorktree
, context
, ref
=oid
))
243 def checkout_detached(self
):
244 """Checkout a commit using an anonymous detached HEAD"""
245 context
= self
.context
246 self
.with_oid(lambda oid
: cmds
.do(cmds
.Checkout
, context
, [oid
]))
248 def save_blob_dialog(self
):
249 """Save a file blob from the selected commit"""
250 context
= self
.context
251 self
.with_oid(lambda oid
: browse
.BrowseBranch
.browse(context
, oid
))
253 def update_menu_actions(self
, event
):
254 """Update menu actions to reflect the selection state"""
255 selected_items
= self
.selected_items()
256 selected_item
= self
.selected_item()
257 item
= self
.itemAt(event
.pos())
259 self
.clicked
= commit
= None
261 self
.clicked
= commit
= item
.commit
263 has_single_selection
= len(selected_items
) == 1
264 has_single_selection_or_clicked
= bool(has_single_selection
or commit
)
265 has_selection
= bool(selected_items
)
268 has_single_selection
and
270 commit
is not selected_items
[0].commit
273 has_single_selection
and
275 bool(selected_item
.commit
.branches
)
277 self
.clicked
and bool(self
.clicked
.branches
)
281 self
.selected
= selected_items
[0].commit
285 self
.menu_actions
['diff_this_selected'].setEnabled(can_diff
)
286 self
.menu_actions
['diff_selected_this'].setEnabled(can_diff
)
287 self
.menu_actions
['diff_commit'].setEnabled(has_single_selection_or_clicked
)
288 self
.menu_actions
['diff_commit_all'].setEnabled(has_single_selection_or_clicked
)
290 self
.menu_actions
['checkout_branch'].setEnabled(has_branches
)
291 self
.menu_actions
['checkout_detached'].setEnabled(has_single_selection_or_clicked
)
292 self
.menu_actions
['cherry_pick'].setEnabled(has_single_selection_or_clicked
)
293 self
.menu_actions
['copy'].setEnabled(has_single_selection_or_clicked
)
294 self
.menu_actions
['create_branch'].setEnabled(has_single_selection_or_clicked
)
295 self
.menu_actions
['create_patch'].setEnabled(has_selection
)
296 self
.menu_actions
['create_tag'].setEnabled(has_single_selection_or_clicked
)
297 self
.menu_actions
['create_tarball'].setEnabled(has_single_selection_or_clicked
)
298 self
.menu_actions
['rebase_to_commit'].setEnabled(has_single_selection_or_clicked
)
299 self
.menu_actions
['reset_mixed'].setEnabled(has_single_selection_or_clicked
)
300 self
.menu_actions
['reset_keep'].setEnabled(has_single_selection_or_clicked
)
301 self
.menu_actions
['reset_merge'].setEnabled(has_single_selection_or_clicked
)
302 self
.menu_actions
['reset_soft'].setEnabled(has_single_selection_or_clicked
)
303 self
.menu_actions
['reset_hard'].setEnabled(has_single_selection_or_clicked
)
304 self
.menu_actions
['restore_worktree'].setEnabled(has_single_selection_or_clicked
)
305 self
.menu_actions
['revert'].setEnabled(has_single_selection_or_clicked
)
306 self
.menu_actions
['save_blob'].setEnabled(has_single_selection_or_clicked
)
308 def context_menu_event(self
, event
):
309 """Build a context menu and execute it"""
310 self
.update_menu_actions(event
)
311 menu
= qtutils
.create_menu(N_('Actions'), self
)
312 menu
.addAction(self
.menu_actions
['diff_this_selected'])
313 menu
.addAction(self
.menu_actions
['diff_selected_this'])
314 menu
.addAction(self
.menu_actions
['diff_commit'])
315 menu
.addAction(self
.menu_actions
['diff_commit_all'])
317 menu
.addAction(self
.menu_actions
['checkout_branch'])
318 menu
.addAction(self
.menu_actions
['create_branch'])
319 menu
.addAction(self
.menu_actions
['create_tag'])
320 menu
.addAction(self
.menu_actions
['rebase_to_commit'])
322 menu
.addAction(self
.menu_actions
['cherry_pick'])
323 menu
.addAction(self
.menu_actions
['revert'])
324 menu
.addAction(self
.menu_actions
['create_patch'])
325 menu
.addAction(self
.menu_actions
['create_tarball'])
327 reset_menu
= menu
.addMenu(N_('Reset'))
328 reset_menu
.addAction(self
.menu_actions
['reset_soft'])
329 reset_menu
.addAction(self
.menu_actions
['reset_mixed'])
330 reset_menu
.addAction(self
.menu_actions
['restore_worktree'])
331 reset_menu
.addSeparator()
332 reset_menu
.addAction(self
.menu_actions
['reset_keep'])
333 reset_menu
.addAction(self
.menu_actions
['reset_merge'])
334 reset_menu
.addAction(self
.menu_actions
['reset_hard'])
335 menu
.addAction(self
.menu_actions
['checkout_detached'])
337 menu
.addAction(self
.menu_actions
['save_blob'])
338 menu
.addAction(self
.menu_actions
['copy'])
339 menu
.exec_(self
.mapToGlobal(event
.pos()))
342 def set_icon(icon
, action
):
343 """ "Set the icon for an action and return the action"""
348 def viewer_actions(widget
):
349 """Return commont actions across the tree and graph widgets"""
351 'diff_this_selected': set_icon(
354 widget
, N_('Diff this -> selected'), widget
.proxy
.diff_this_selected
357 'diff_selected_this': set_icon(
360 widget
, N_('Diff selected -> this'), widget
.proxy
.diff_selected_this
363 'create_branch': set_icon(
365 qtutils
.add_action(widget
, N_('Create Branch'), widget
.proxy
.create_branch
),
367 'create_patch': set_icon(
369 qtutils
.add_action(widget
, N_('Create Patch'), widget
.proxy
.create_patch
),
371 'create_tag': set_icon(
373 qtutils
.add_action(widget
, N_('Create Tag'), widget
.proxy
.create_tag
),
375 'create_tarball': set_icon(
378 widget
, N_('Save As Tarball/Zip...'), widget
.proxy
.create_tarball
381 'cherry_pick': set_icon(
383 qtutils
.add_action(widget
, N_('Cherry Pick'), widget
.proxy
.cherry_pick
),
386 icons
.undo(), qtutils
.add_action(widget
, N_('Revert'), widget
.proxy
.revert
)
388 'diff_commit': set_icon(
391 widget
, N_('Launch Diff Tool'), widget
.proxy
.show_diff
, hotkeys
.DIFF
394 'diff_commit_all': set_icon(
398 N_('Launch Directory Diff Tool'),
399 widget
.proxy
.show_dir_diff
,
400 hotkeys
.DIFF_SECONDARY
,
403 'checkout_branch': set_icon(
406 widget
, N_('Checkout Branch'), widget
.proxy
.checkout_branch
409 'checkout_detached': qtutils
.add_action(
410 widget
, N_('Checkout Detached HEAD'), widget
.proxy
.checkout_detached
412 'rebase_to_commit': set_icon(
415 widget
, N_('Rebase to this commit'), widget
.proxy
.rebase_to_commit
418 'reset_soft': set_icon(
419 icons
.style_dialog_reset(),
421 widget
, N_('Reset Branch (Soft)'), widget
.proxy
.reset_soft
424 'reset_mixed': set_icon(
425 icons
.style_dialog_reset(),
427 widget
, N_('Reset Branch and Stage (Mixed)'), widget
.proxy
.reset_mixed
430 'reset_keep': set_icon(
431 icons
.style_dialog_reset(),
434 N_('Restore Worktree and Reset All (Keep Unstaged Edits)'),
435 widget
.proxy
.reset_keep
,
438 'reset_merge': set_icon(
439 icons
.style_dialog_reset(),
442 N_('Restore Worktree and Reset All (Merge)'),
443 widget
.proxy
.reset_merge
,
446 'reset_hard': set_icon(
447 icons
.style_dialog_reset(),
450 N_('Restore Worktree and Reset All (Hard)'),
451 widget
.proxy
.reset_hard
,
454 'restore_worktree': set_icon(
457 widget
, N_('Restore Worktree'), widget
.proxy
.restore_worktree
460 'save_blob': set_icon(
463 widget
, N_('Grab File...'), widget
.proxy
.save_blob_dialog
471 widget
.proxy
.copy_to_clipboard
,
478 class CommitTreeWidgetItem(QtWidgets
.QTreeWidgetItem
):
479 """Custom TreeWidgetItem used in to build the commit tree widget"""
481 def __init__(self
, commit
, parent
=None):
482 QtWidgets
.QTreeWidgetItem
.__init
__(self
, parent
)
484 self
.setText(0, commit
.summary
)
485 self
.setText(1, commit
.author
)
486 self
.setText(2, commit
.authdate
)
489 # pylint: disable=too-many-ancestors
490 class CommitTreeWidget(standard
.TreeWidget
, ViewerMixin
):
491 """Display commits using a flat treewidget in "list" mode"""
493 commits_selected
= Signal(object)
494 diff_commits
= Signal(object, object)
495 zoom_to_fit
= Signal()
497 def __init__(self
, context
, parent
):
498 standard
.TreeWidget
.__init
__(self
, parent
)
499 ViewerMixin
.__init
__(self
)
501 self
.setSelectionMode(self
.ExtendedSelection
)
502 self
.setHeaderLabels([N_('Summary'), N_('Author'), N_('Date, Time')])
504 self
.context
= context
506 self
.menu_actions
= None
507 self
.selecting
= False
509 self
._adjust
_columns
= False
511 self
.action_up
= qtutils
.add_action(
512 self
, N_('Go Up'), self
.go_up
, hotkeys
.MOVE_UP
515 self
.action_down
= qtutils
.add_action(
516 self
, N_('Go Down'), self
.go_down
, hotkeys
.MOVE_DOWN
519 self
.zoom_to_fit_action
= qtutils
.add_action(
520 self
, N_('Zoom to Fit'), self
.zoom_to_fit
.emit
, hotkeys
.FIT
523 # pylint: disable=no-member
524 self
.itemSelectionChanged
.connect(self
.selection_changed
)
526 def export_state(self
):
527 """Export the widget's state"""
528 # The base class method is intentionally overridden because we only
529 # care about the details below for this subwidget.
531 state
['column_widths'] = self
.column_widths()
534 def apply_state(self
, state
):
535 """Apply the exported widget state"""
537 column_widths
= state
['column_widths']
538 except (KeyError, ValueError):
541 self
.set_column_widths(column_widths
)
543 # Defer showing the columns until we are shown, and our true width
544 # is known. Calling adjust_columns() here ends up with the wrong
545 # answer because we have not yet been parented to the layout.
546 # We set this flag that we process once during our initial
548 self
._adjust
_columns
= True
552 def showEvent(self
, event
):
553 """Override QWidget::showEvent() to size columns when we are shown"""
554 if self
._adjust
_columns
:
555 self
._adjust
_columns
= False
557 two_thirds
= (width
* 2) // 3
558 one_sixth
= width
// 6
560 self
.setColumnWidth(0, two_thirds
)
561 self
.setColumnWidth(1, one_sixth
)
562 self
.setColumnWidth(2, one_sixth
)
563 return standard
.TreeWidget
.showEvent(self
, event
)
567 """Select the item above the current item"""
568 self
.goto(self
.itemAbove
)
571 """Select the item below the current item"""
572 self
.goto(self
.itemBelow
)
574 def goto(self
, finder
):
575 """Move the selection using a finder strategy"""
576 items
= self
.selected_items()
577 item
= items
[0] if items
else None
582 self
.select([found
.commit
.oid
])
584 def selected_commit_range(self
):
585 """Return a range of selected commits"""
586 selected_items
= self
.selected_items()
587 if not selected_items
:
589 return selected_items
[-1].commit
.oid
, selected_items
[0].commit
.oid
591 def set_selecting(self
, selecting
):
592 """Record the "are we selecting?" status"""
593 self
.selecting
= selecting
595 def selection_changed(self
):
596 """Respond to itemSelectionChanged notifications"""
597 items
= self
.selected_items()
599 self
.set_selecting(True)
600 self
.commits_selected
.emit([])
601 self
.set_selecting(False)
603 self
.set_selecting(True)
604 self
.commits_selected
.emit(sort_by_generation([i
.commit
for i
in items
]))
605 self
.set_selecting(False)
607 def select_commits(self
, commits
):
608 """Select commits that were selected by the sibling tree/graph widget"""
611 with qtutils
.BlockSignals(self
):
612 self
.select([commit
.oid
for commit
in commits
])
614 def select(self
, oids
):
615 """Mark items as selected"""
616 self
.clearSelection()
621 item
= self
.oidmap
[oid
]
624 self
.scrollToItem(item
)
625 item
.setSelected(True)
629 QtWidgets
.QTreeWidget
.clear(self
)
633 def add_commits(self
, commits
):
634 """Add commits to the tree"""
635 self
.commits
.extend(commits
)
637 for c
in reversed(commits
):
638 item
= CommitTreeWidgetItem(c
)
640 self
.oidmap
[c
.oid
] = item
642 self
.oidmap
[tag
] = item
643 self
.insertTopLevelItems(0, items
)
645 def create_patch(self
):
646 """Export a patch from the selected items"""
647 items
= self
.selectedItems()
650 context
= self
.context
651 oids
= [item
.commit
.oid
for item
in reversed(items
)]
652 all_oids
= [c
.oid
for c
in self
.commits
]
653 cmds
.do(cmds
.FormatPatch
, context
, oids
, all_oids
)
656 def contextMenuEvent(self
, event
):
657 """Create a custom context menu and execute it"""
658 self
.context_menu_event(event
)
660 def mousePressEvent(self
, event
):
661 """Intercept the right-click event to retain selection state"""
662 item
= self
.itemAt(event
.pos())
666 self
.clicked
= item
.commit
667 if event
.button() == Qt
.RightButton
:
670 QtWidgets
.QTreeWidget
.mousePressEvent(self
, event
)
673 class GitDAG(standard
.MainWindow
):
674 """The git-dag widget."""
676 commits_selected
= Signal(object)
678 def __init__(self
, context
, params
, parent
=None):
679 super(GitDAG
, self
).__init
__(parent
)
681 self
.setMinimumSize(420, 420)
683 # change when widgets are added/removed
684 self
.widget_version
= 2
685 self
.context
= context
687 self
.model
= context
.model
690 self
.commit_list
= []
692 self
.old_refs
= set()
695 self
.force_refresh
= False
698 self
.revtext
= completion
.GitLogLineEdit(context
)
699 self
.maxresults
= standard
.SpinBox()
701 self
.zoom_out
= qtutils
.create_action_button(
702 tooltip
=N_('Zoom Out'), icon
=icons
.zoom_out()
705 self
.zoom_in
= qtutils
.create_action_button(
706 tooltip
=N_('Zoom In'), icon
=icons
.zoom_in()
709 self
.zoom_to_fit
= qtutils
.create_action_button(
710 tooltip
=N_('Zoom to Fit'), icon
=icons
.zoom_fit_best()
713 self
.treewidget
= CommitTreeWidget(context
, self
)
714 self
.diffwidget
= diff
.DiffWidget(context
, self
, is_commit
=True)
715 self
.filewidget
= filelist
.FileWidget(context
, self
)
716 self
.graphview
= GraphView(context
, self
)
718 self
.treewidget
.commits_selected
.connect(self
.commits_selected
)
719 self
.graphview
.commits_selected
.connect(self
.commits_selected
)
721 self
.commits_selected
.connect(self
.select_commits
)
722 self
.commits_selected
.connect(self
.diffwidget
.commits_selected
)
723 self
.commits_selected
.connect(self
.filewidget
.commits_selected
)
724 self
.commits_selected
.connect(self
.graphview
.select_commits
)
725 self
.commits_selected
.connect(self
.treewidget
.select_commits
)
727 self
.filewidget
.files_selected
.connect(self
.diffwidget
.files_selected
)
728 self
.filewidget
.difftool_selected
.connect(self
.difftool_selected
)
729 self
.filewidget
.histories_selected
.connect(self
.histories_selected
)
731 self
.proxy
= FocusRedirectProxy(
732 self
.treewidget
, self
.graphview
, self
.filewidget
735 self
.viewer_actions
= actions
= viewer_actions(self
)
736 self
.treewidget
.menu_actions
= actions
737 self
.graphview
.menu_actions
= actions
739 self
.controls_layout
= qtutils
.hbox(
740 defs
.no_margin
, defs
.spacing
, self
.revtext
, self
.maxresults
743 self
.controls_widget
= QtWidgets
.QWidget()
744 self
.controls_widget
.setLayout(self
.controls_layout
)
746 self
.log_dock
= qtutils
.create_dock('Log', N_('Log'), self
, stretch
=False)
747 self
.log_dock
.setWidget(self
.treewidget
)
748 log_dock_titlebar
= self
.log_dock
.titleBarWidget()
749 log_dock_titlebar
.add_corner_widget(self
.controls_widget
)
751 self
.file_dock
= qtutils
.create_dock('Files', N_('Files'), self
)
752 self
.file_dock
.setWidget(self
.filewidget
)
754 self
.diff_options
= diff
.Options(self
.diffwidget
)
755 self
.diffwidget
.set_options(self
.diff_options
)
756 self
.diff_options
.hide_advanced_options()
757 self
.diff_options
.set_diff_type(main
.Types
.TEXT
)
759 self
.diff_dock
= qtutils
.create_dock('Diff', N_('Diff'), self
)
760 self
.diff_dock
.setWidget(self
.diffwidget
)
762 diff_titlebar
= self
.diff_dock
.titleBarWidget()
763 diff_titlebar
.add_corner_widget(self
.diff_options
)
765 self
.graph_controls_layout
= qtutils
.hbox(
774 self
.graph_controls_widget
= QtWidgets
.QWidget()
775 self
.graph_controls_widget
.setLayout(self
.graph_controls_layout
)
777 self
.graphview_dock
= qtutils
.create_dock('Graph', N_('Graph'), self
)
778 self
.graphview_dock
.setWidget(self
.graphview
)
779 graph_titlebar
= self
.graphview_dock
.titleBarWidget()
780 graph_titlebar
.add_corner_widget(self
.graph_controls_widget
)
782 self
.lock_layout_action
= qtutils
.add_action_bool(
783 self
, N_('Lock Layout'), self
.set_lock_layout
, False
786 self
.refresh_action
= qtutils
.add_action(
787 self
, N_('Refresh'), self
.refresh
, hotkeys
.REFRESH
790 # Create the application menu
791 self
.menubar
= QtWidgets
.QMenuBar(self
)
792 self
.setMenuBar(self
.menubar
)
795 self
.view_menu
= qtutils
.add_menu(N_('View'), self
.menubar
)
796 self
.view_menu
.addAction(self
.refresh_action
)
797 self
.view_menu
.addAction(self
.log_dock
.toggleViewAction())
798 self
.view_menu
.addAction(self
.graphview_dock
.toggleViewAction())
799 self
.view_menu
.addAction(self
.diff_dock
.toggleViewAction())
800 self
.view_menu
.addAction(self
.file_dock
.toggleViewAction())
801 self
.view_menu
.addSeparator()
802 self
.view_menu
.addAction(self
.lock_layout_action
)
804 left
= Qt
.LeftDockWidgetArea
805 right
= Qt
.RightDockWidgetArea
806 self
.addDockWidget(left
, self
.log_dock
)
807 self
.addDockWidget(left
, self
.diff_dock
)
808 self
.addDockWidget(right
, self
.graphview_dock
)
809 self
.addDockWidget(right
, self
.file_dock
)
811 # Also re-loads dag.* from the saved state
812 self
.init_state(context
.settings
, self
.resize_to_desktop
)
814 qtutils
.connect_button(self
.zoom_out
, self
.graphview
.zoom_out
)
815 qtutils
.connect_button(self
.zoom_in
, self
.graphview
.zoom_in
)
816 qtutils
.connect_button(self
.zoom_to_fit
, self
.graphview
.zoom_to_fit
)
818 self
.treewidget
.zoom_to_fit
.connect(self
.graphview
.zoom_to_fit
)
819 self
.treewidget
.diff_commits
.connect(self
.diff_commits
)
820 self
.graphview
.diff_commits
.connect(self
.diff_commits
)
821 self
.filewidget
.grab_file
.connect(self
.grab_file
)
823 # pylint: disable=no-member
824 self
.maxresults
.editingFinished
.connect(self
.display
)
826 self
.revtext
.textChanged
.connect(self
.text_changed
)
827 self
.revtext
.activated
.connect(self
.display
)
828 self
.revtext
.enter
.connect(self
.display
)
829 self
.revtext
.down
.connect(self
.focus_tree
)
831 # The model is updated in another thread so use
832 # signals/slots to bring control back to the main GUI thread
833 self
.model
.updated
.connect(self
.model_updated
, type=Qt
.QueuedConnection
)
835 qtutils
.add_action(self
, 'FocusInput', self
.focus_input
, hotkeys
.FOCUS_INPUT
)
836 qtutils
.add_action(self
, 'FocusTree', self
.focus_tree
, hotkeys
.FOCUS_TREE
)
837 qtutils
.add_action(self
, 'FocusDiff', self
.focus_diff
, hotkeys
.FOCUS_DIFF
)
838 qtutils
.add_close_action(self
)
840 self
.set_params(params
)
842 def set_params(self
, params
):
843 context
= self
.context
846 # Update fields affected by model
847 self
.revtext
.setText(params
.ref
)
848 self
.maxresults
.setValue(params
.count
)
849 self
.update_window_title()
851 if self
.thread
is not None:
854 self
.thread
= ReaderThread(context
, params
, self
)
857 thread
.begin
.connect(self
.thread_begin
, type=Qt
.QueuedConnection
)
858 thread
.status
.connect(self
.thread_status
, type=Qt
.QueuedConnection
)
859 thread
.add
.connect(self
.add_commits
, type=Qt
.QueuedConnection
)
860 thread
.end
.connect(self
.thread_end
, type=Qt
.QueuedConnection
)
862 def focus_input(self
):
863 """Focus the revision input field"""
864 self
.revtext
.setFocus()
866 def focus_tree(self
):
867 """Focus the revision tree list widget"""
868 self
.treewidget
.setFocus()
870 def focus_diff(self
):
871 """Focus the diff widget"""
872 self
.diffwidget
.setFocus()
874 def text_changed(self
, txt
):
875 self
.params
.ref
= txt
876 self
.update_window_title()
878 def update_window_title(self
):
879 project
= self
.model
.project
882 N_('%(project)s: %(ref)s - DAG')
883 % dict(project
=project
, ref
=self
.params
.ref
)
886 self
.setWindowTitle(project
+ N_(' - DAG'))
888 def export_state(self
):
889 state
= standard
.MainWindow
.export_state(self
)
890 state
['count'] = self
.params
.count
891 state
['log'] = self
.treewidget
.export_state()
892 state
['word_wrap'] = self
.diffwidget
.options
.enable_word_wrapping
.isChecked()
895 def apply_state(self
, state
):
896 result
= standard
.MainWindow
.apply_state(self
, state
)
898 count
= state
['count']
899 if self
.params
.overridden('count'):
900 count
= self
.params
.count
901 except (KeyError, TypeError, ValueError, AttributeError):
902 count
= self
.params
.count
904 self
.params
.set_count(count
)
905 self
.lock_layout_action
.setChecked(state
.get('lock_layout', False))
906 self
.diffwidget
.set_word_wrapping(state
.get('word_wrap', False), update
=True)
909 log_state
= state
['log']
910 except (KeyError, ValueError):
913 self
.treewidget
.apply_state(log_state
)
917 def model_updated(self
):
919 self
.update_window_title()
922 """Unconditionally refresh the DAG"""
923 # self.force_refresh triggers an Unconditional redraw
924 self
.force_refresh
= True
925 cmds
.do(cmds
.Refresh
, self
.context
)
926 self
.force_refresh
= False
929 """Update the view when the Git refs change"""
930 ref
= get(self
.revtext
)
931 count
= get(self
.maxresults
)
932 context
= self
.context
934 # The DAG tries to avoid updating when the object IDs have not
935 # changed. Without doing this the DAG constantly redraws itself
936 # whenever inotify sends update events, which hurts usability.
938 # To minimize redraws we leverage `git rev-parse`. The strategy is to
939 # use `git rev-parse` on the input line, which converts each argument
940 # into object IDs. From there it's a simple matter of detecting when
941 # the object IDs changed.
943 # In addition to object IDs, we also need to know when the set of
944 # named references (branches, tags) changes so that an update is
945 # triggered when new branches and tags are created.
946 refs
= set(model
.local_branches
+ model
.remote_branches
+ model
.tags
)
947 argv
= utils
.shell_split(ref
or 'HEAD')
948 oids
= gitcmds
.parse_refs(context
, argv
)
951 or count
!= self
.old_count
952 or oids
!= self
.old_oids
953 or refs
!= self
.old_refs
957 self
.params
.set_ref(ref
)
958 self
.params
.set_count(count
)
962 self
.old_count
= count
965 def select_commits(self
, commits
):
966 self
.selection
= commits
970 self
.commit_list
= []
971 self
.graphview
.clear()
972 self
.treewidget
.clear()
974 def add_commits(self
, commits
):
975 self
.commit_list
.extend(commits
)
976 # Keep track of commits
977 for commit_obj
in commits
:
978 self
.commits
[commit_obj
.oid
] = commit_obj
979 for tag
in commit_obj
.tags
:
980 self
.commits
[tag
] = commit_obj
981 self
.graphview
.add_commits(commits
)
982 self
.treewidget
.add_commits(commits
)
984 def thread_begin(self
):
987 def thread_end(self
):
988 self
.restore_selection()
990 def thread_status(self
, successful
):
991 self
.revtext
.hint
.set_error(not successful
)
993 def restore_selection(self
):
994 selection
= self
.selection
996 commit_obj
= self
.commit_list
[-1]
998 # No commits, exist, early-out
1001 new_commits
= [self
.commits
.get(s
.oid
, None) for s
in selection
]
1002 new_commits
= [c
for c
in new_commits
if c
is not None]
1004 # The old selection exists in the new state
1005 self
.commits_selected
.emit(sort_by_generation(new_commits
))
1007 # The old selection is now empty. Select the top-most commit
1008 self
.commits_selected
.emit([commit_obj
])
1010 self
.graphview
.set_initial_view()
1012 def diff_commits(self
, a
, b
):
1013 paths
= self
.params
.paths()
1015 cmds
.difftool_launch(self
.context
, left
=a
, right
=b
, paths
=paths
)
1017 difftool
.diff_commits(self
.context
, self
, a
, b
)
1020 def closeEvent(self
, event
):
1021 self
.revtext
.close_popup()
1023 standard
.MainWindow
.closeEvent(self
, event
)
1025 def histories_selected(self
, histories
):
1026 argv
= [self
.model
.currentbranch
, '--']
1027 argv
.extend(histories
)
1028 text
= core
.list2cmdline(argv
)
1029 self
.revtext
.setText(text
)
1032 def difftool_selected(self
, files
):
1033 bottom
, top
= self
.treewidget
.selected_commit_range()
1036 cmds
.difftool_launch(
1037 self
.context
, left
=bottom
, left_take_parent
=True, right
=top
, paths
=files
1040 def grab_file(self
, filename
):
1041 """Save the selected file from the filelist widget"""
1042 oid
= self
.treewidget
.selected_oid()
1043 model
= browse
.BrowseModel(oid
, filename
=filename
)
1044 browse
.save_path(self
.context
, filename
, model
)
1047 class ReaderThread(QtCore
.QThread
):
1049 add
= Signal(object)
1051 status
= Signal(object)
1053 def __init__(self
, context
, params
, parent
):
1054 QtCore
.QThread
.__init
__(self
, parent
)
1055 self
.context
= context
1056 self
.params
= params
1059 self
._mutex
= QtCore
.QMutex()
1060 self
._condition
= QtCore
.QWaitCondition()
1063 context
= self
.context
1064 repo
= dag
.RepoReader(context
, self
.params
)
1068 for c
in repo
.get():
1071 self
._condition
.wait(self
._mutex
)
1072 self
._mutex
.unlock()
1077 if len(commits
) >= 512:
1078 self
.add
.emit(commits
)
1081 self
.status
.emit(repo
.returncode
== 0)
1083 self
.add
.emit(commits
)
1089 QtCore
.QThread
.start(self
)
1094 self
._mutex
.unlock()
1099 self
._mutex
.unlock()
1100 self
._condition
.wakeOne()
1107 class Cache(object):
1112 def label_font(cls
):
1113 font
= cls
._label
_font
1115 font
= cls
._label
_font
= QtWidgets
.QApplication
.font()
1116 font
.setPointSize(6)
1120 class Edge(QtWidgets
.QGraphicsItem
):
1121 item_type
= qtutils
.standard_item_type_value(1)
1123 def __init__(self
, source
, dest
):
1125 QtWidgets
.QGraphicsItem
.__init
__(self
)
1127 self
.setAcceptedMouseButtons(Qt
.NoButton
)
1128 self
.source
= source
1130 self
.commit
= source
.commit
1133 self
.recompute_bound()
1135 self
.path_valid
= False
1137 # Choose a new color for new branch edges
1138 if self
.source
.x() < self
.dest
.x():
1139 color
= EdgeColor
.cycle()
1141 elif self
.source
.x() != self
.dest
.x():
1142 color
= EdgeColor
.current()
1145 color
= EdgeColor
.current()
1148 self
.pen
= QtGui
.QPen(color
, 4.0, line
, Qt
.SquareCap
, Qt
.RoundJoin
)
1150 def recompute_bound(self
):
1151 dest_pt
= Commit
.item_bbox
.center()
1153 self
.source_pt
= self
.mapFromItem(self
.source
, dest_pt
)
1154 self
.dest_pt
= self
.mapFromItem(self
.dest
, dest_pt
)
1155 self
.line
= QtCore
.QLineF(self
.source_pt
, self
.dest_pt
)
1157 width
= self
.dest_pt
.x() - self
.source_pt
.x()
1158 height
= self
.dest_pt
.y() - self
.source_pt
.y()
1159 rect
= QtCore
.QRectF(self
.source_pt
, QtCore
.QSizeF(width
, height
))
1160 self
.bound
= rect
.normalized()
1162 def commits_were_invalidated(self
):
1163 self
.recompute_bound()
1164 self
.prepareGeometryChange()
1165 # The path should not be recomputed immediately because just small part
1166 # of DAG is actually shown at same time. It will be recomputed on
1167 # demand in course of 'paint' method.
1168 self
.path_valid
= False
1169 # Hence, just queue redrawing.
1174 return self
.item_type
1176 def boundingRect(self
):
1179 def recompute_path(self
):
1180 QRectF
= QtCore
.QRectF
1181 QPointF
= QtCore
.QPointF
1184 connector_length
= 5
1186 path
= QtGui
.QPainterPath()
1188 if self
.source
.x() == self
.dest
.x():
1189 path
.moveTo(self
.source
.x(), self
.source
.y())
1190 path
.lineTo(self
.dest
.x(), self
.dest
.y())
1192 # Define points starting from source
1193 point1
= QPointF(self
.source
.x(), self
.source
.y())
1194 point2
= QPointF(point1
.x(), point1
.y() - connector_length
)
1195 point3
= QPointF(point2
.x() + arc_rect
, point2
.y() - arc_rect
)
1197 # Define points starting from dest
1198 point4
= QPointF(self
.dest
.x(), self
.dest
.y())
1199 point5
= QPointF(point4
.x(), point3
.y() - arc_rect
)
1200 point6
= QPointF(point5
.x() - arc_rect
, point5
.y() + arc_rect
)
1202 start_angle_arc1
= 180
1203 span_angle_arc1
= 90
1204 start_angle_arc2
= 90
1205 span_angle_arc2
= -90
1207 # If the dest is at the left of the source, then we
1208 # need to reverse some values
1209 if self
.source
.x() > self
.dest
.x():
1210 point3
= QPointF(point2
.x() - arc_rect
, point3
.y())
1211 point6
= QPointF(point5
.x() + arc_rect
, point6
.y())
1213 span_angle_arc1
= 90
1217 path
.arcTo(QRectF(point2
, point3
), start_angle_arc1
, span_angle_arc1
)
1219 path
.arcTo(QRectF(point6
, point5
), start_angle_arc2
, span_angle_arc2
)
1223 self
.path_valid
= True
1225 def paint(self
, painter
, _option
, _widget
):
1226 if not self
.path_valid
:
1227 self
.recompute_path()
1228 painter
.setPen(self
.pen
)
1229 painter
.drawPath(self
.path
)
1232 class EdgeColor(object):
1233 """An edge color factory"""
1235 current_color_index
= 0
1237 QtGui
.QColor(Qt
.red
),
1238 QtGui
.QColor(Qt
.cyan
),
1239 QtGui
.QColor(Qt
.magenta
),
1240 QtGui
.QColor(Qt
.green
),
1241 # Orange; Qt.yellow is too low-contrast
1242 qtutils
.rgba(0xFF, 0x66, 0x00),
1246 def update_colors(cls
, theme
):
1247 """Update the colors based on the color theme"""
1248 if theme
.is_dark
or theme
.is_palette_dark
:
1250 QtGui
.QColor(Qt
.red
).lighter(),
1251 QtGui
.QColor(Qt
.cyan
).lighter(),
1252 QtGui
.QColor(Qt
.magenta
).lighter(),
1253 QtGui
.QColor(Qt
.green
).lighter(),
1254 QtGui
.QColor(Qt
.yellow
).lighter(),
1258 QtGui
.QColor(Qt
.blue
),
1259 QtGui
.QColor(Qt
.darkRed
),
1260 QtGui
.QColor(Qt
.darkCyan
),
1261 QtGui
.QColor(Qt
.darkMagenta
),
1262 QtGui
.QColor(Qt
.darkGreen
),
1263 QtGui
.QColor(Qt
.darkYellow
),
1264 QtGui
.QColor(Qt
.darkBlue
),
1269 cls
.current_color_index
+= 1
1270 cls
.current_color_index
%= len(cls
.colors
)
1271 color
= cls
.colors
[cls
.current_color_index
]
1277 return cls
.colors
[cls
.current_color_index
]
1281 cls
.current_color_index
= 0
1284 class Commit(QtWidgets
.QGraphicsItem
):
1285 item_type
= qtutils
.standard_item_type_value(2)
1286 commit_radius
= 12.0
1289 item_shape
= QtGui
.QPainterPath()
1291 commit_radius
/ -2.0, commit_radius
/ -2.0, commit_radius
, commit_radius
1293 item_bbox
= item_shape
.boundingRect()
1295 inner_rect
= QtGui
.QPainterPath()
1297 commit_radius
/ -2.0 + 2.0,
1298 commit_radius
/ -2.0 + 2.0,
1299 commit_radius
- 4.0,
1300 commit_radius
- 4.0,
1302 inner_rect
= inner_rect
.boundingRect()
1304 commit_color
= QtGui
.QColor(Qt
.white
)
1305 outline_color
= commit_color
.darker()
1306 merge_color
= QtGui
.QColor(Qt
.lightGray
)
1308 commit_selected_color
= QtGui
.QColor(Qt
.green
)
1309 selected_outline_color
= commit_selected_color
.darker()
1311 commit_pen
= QtGui
.QPen()
1312 commit_pen
.setWidth(1)
1313 commit_pen
.setColor(outline_color
)
1318 selectable
=QtWidgets
.QGraphicsItem
.ItemIsSelectable
,
1319 cursor
=Qt
.PointingHandCursor
,
1320 xpos
=commit_radius
/ 2.0 + 1.0,
1321 cached_commit_color
=commit_color
,
1322 cached_merge_color
=merge_color
,
1324 QtWidgets
.QGraphicsItem
.__init
__(self
)
1326 self
.commit
= commit
1327 self
.selected
= False
1330 self
.setFlag(selectable
)
1331 self
.setCursor(cursor
)
1332 self
.setToolTip(commit
.oid
[:12] + ': ' + commit
.summary
)
1335 self
.label
= label
= Label(commit
)
1336 label
.setParentItem(self
)
1337 label
.setPos(xpos
+ 1, -self
.commit_radius
/ 2.0)
1341 if len(commit
.parents
) > 1:
1342 self
.brush
= cached_merge_color
1344 self
.brush
= cached_commit_color
1346 self
.pressed
= False
1347 self
.dragged
= False
1350 def itemChange(self
, change
, value
):
1351 if change
== QtWidgets
.QGraphicsItem
.ItemSelectedHasChanged
:
1352 # Cache the pen for use in paint()
1354 self
.brush
= self
.commit_selected_color
1355 color
= self
.selected_outline_color
1357 if len(self
.commit
.parents
) > 1:
1358 self
.brush
= self
.merge_color
1360 self
.brush
= self
.commit_color
1361 color
= self
.outline_color
1362 commit_pen
= QtGui
.QPen()
1363 commit_pen
.setWidth(1)
1364 commit_pen
.setColor(color
)
1365 self
.commit_pen
= commit_pen
1367 return QtWidgets
.QGraphicsItem
.itemChange(self
, change
, value
)
1370 return self
.item_type
1372 def boundingRect(self
):
1373 return self
.item_bbox
1376 return self
.item_shape
1378 def paint(self
, painter
, option
, _widget
):
1380 # Do not draw outside the exposed rect
1381 painter
.setClipRect(option
.exposedRect
)
1384 painter
.setPen(self
.commit_pen
)
1385 painter
.setBrush(self
.brush
)
1386 painter
.drawEllipse(self
.inner_rect
)
1388 def mousePressEvent(self
, event
):
1389 QtWidgets
.QGraphicsItem
.mousePressEvent(self
, event
)
1391 self
.selected
= self
.isSelected()
1393 def mouseMoveEvent(self
, event
):
1396 QtWidgets
.QGraphicsItem
.mouseMoveEvent(self
, event
)
1398 def mouseReleaseEvent(self
, event
):
1399 QtWidgets
.QGraphicsItem
.mouseReleaseEvent(self
, event
)
1400 if not self
.dragged
and self
.selected
and event
.button() == Qt
.LeftButton
:
1402 self
.pressed
= False
1403 self
.dragged
= False
1406 class Label(QtWidgets
.QGraphicsItem
):
1408 item_type
= qtutils
.graphics_item_type_value(3)
1410 head_color
= QtGui
.QColor(Qt
.green
)
1411 other_color
= QtGui
.QColor(Qt
.white
)
1412 remote_color
= QtGui
.QColor(Qt
.yellow
)
1414 head_pen
= QtGui
.QPen()
1415 head_pen
.setColor(head_color
.darker().darker())
1416 head_pen
.setWidth(1)
1418 text_pen
= QtGui
.QPen()
1419 text_pen
.setColor(QtGui
.QColor(Qt
.black
))
1420 text_pen
.setWidth(1)
1423 head_color
.setAlpha(alpha
)
1424 other_color
.setAlpha(alpha
)
1425 remote_color
.setAlpha(alpha
)
1431 def __init__(self
, commit
):
1432 QtWidgets
.QGraphicsItem
.__init
__(self
)
1434 self
.commit
= commit
1437 return self
.item_type
1439 def boundingRect(self
, cache
=Cache
):
1440 QPainterPath
= QtGui
.QPainterPath
1441 QRectF
= QtCore
.QRectF
1446 spacing
= self
.item_spacing
1447 border
= self
.border
+ self
.text_offset
# text offset=1 in paint()
1449 font
= cache
.label_font()
1450 item_shape
= QPainterPath()
1452 base_rect
= QRectF(0, 0, width
, height
)
1453 base_rect
= base_rect
.adjusted(-border
, -border
, border
, border
)
1454 item_shape
.addRect(base_rect
)
1456 for tag
in self
.commit
.tags
:
1457 text_shape
= QPainterPath()
1458 text_shape
.addText(current_width
, 0, font
, tag
)
1459 text_rect
= text_shape
.boundingRect()
1460 box_rect
= text_rect
.adjusted(-border
, -border
, border
, border
)
1461 item_shape
.addRect(box_rect
)
1462 current_width
= item_shape
.boundingRect().width() + spacing
1464 return item_shape
.boundingRect()
1466 def paint(self
, painter
, _option
, _widget
, cache
=Cache
):
1467 # Draw tags and branches
1468 font
= cache
.label_font()
1469 painter
.setFont(font
)
1472 border
= self
.border
1473 offset
= self
.text_offset
1474 spacing
= self
.item_spacing
1475 QRectF
= QtCore
.QRectF
1478 remotes_prefix
= 'remotes/'
1479 tags_prefix
= 'tags/'
1480 heads_prefix
= 'heads/'
1481 remotes_len
= len(remotes_prefix
)
1482 tags_len
= len(tags_prefix
)
1483 heads_len
= len(heads_prefix
)
1485 for tag
in self
.commit
.tags
:
1487 painter
.setPen(self
.text_pen
)
1488 painter
.setBrush(self
.remote_color
)
1489 elif tag
.startswith(remotes_prefix
):
1490 tag
= tag
[remotes_len
:]
1491 painter
.setPen(self
.text_pen
)
1492 painter
.setBrush(self
.other_color
)
1493 elif tag
.startswith(tags_prefix
):
1494 tag
= tag
[tags_len
:]
1495 painter
.setPen(self
.text_pen
)
1496 painter
.setBrush(self
.remote_color
)
1497 elif tag
.startswith(heads_prefix
):
1498 tag
= tag
[heads_len
:]
1499 painter
.setPen(self
.head_pen
)
1500 painter
.setBrush(self
.head_color
)
1502 painter
.setPen(self
.text_pen
)
1503 painter
.setBrush(self
.other_color
)
1505 text_rect
= painter
.boundingRect(
1506 QRectF(current_width
, 0, 0, 0), Qt
.TextSingleLine
, tag
1508 box_rect
= text_rect
.adjusted(-offset
, -offset
, offset
, offset
)
1510 painter
.drawRoundedRect(box_rect
, border
, border
)
1511 painter
.drawText(text_rect
, Qt
.TextSingleLine
, tag
)
1512 current_width
+= text_rect
.width() + spacing
1515 # pylint: disable=too-many-ancestors
1516 class GraphView(QtWidgets
.QGraphicsView
, ViewerMixin
):
1518 commits_selected
= Signal(object)
1519 diff_commits
= Signal(object, object)
1521 x_adjust
= int(Commit
.commit_radius
* 4 / 3)
1522 y_adjust
= int(Commit
.commit_radius
* 4 / 3)
1527 def __init__(self
, context
, parent
):
1528 QtWidgets
.QGraphicsView
.__init
__(self
, parent
)
1529 ViewerMixin
.__init
__(self
)
1530 EdgeColor
.update_colors(context
.app
.theme
)
1532 theme
= context
.app
.theme
1533 highlight
= theme
.selection_color()
1534 Commit
.commit_selected_color
= highlight
1535 Commit
.selected_outline_color
= highlight
.darker()
1537 self
.context
= context
1539 self
.menu_actions
= None
1542 self
.mouse_start
= [0, 0]
1543 self
.saved_matrix
= self
.transform()
1547 self
.tagged_cells
= set()
1551 self
.x_offsets
= collections
.defaultdict(lambda: self
.x_min
)
1553 self
.is_panning
= False
1554 self
.pressed
= False
1555 self
.selecting
= False
1556 self
.last_mouse
= [0, 0]
1558 self
.setDragMode(self
.RubberBandDrag
)
1560 scene
= QtWidgets
.QGraphicsScene(self
)
1561 scene
.setItemIndexMethod(QtWidgets
.QGraphicsScene
.BspTreeIndex
)
1562 self
.setScene(scene
)
1564 # pylint: disable=no-member
1565 scene
.selectionChanged
.connect(self
.selection_changed
)
1567 self
.setRenderHint(QtGui
.QPainter
.Antialiasing
)
1568 self
.setViewportUpdateMode(self
.SmartViewportUpdate
)
1569 self
.setCacheMode(QtWidgets
.QGraphicsView
.CacheBackground
)
1570 self
.setTransformationAnchor(QtWidgets
.QGraphicsView
.AnchorUnderMouse
)
1571 self
.setResizeAnchor(QtWidgets
.QGraphicsView
.NoAnchor
)
1573 background_color
= qtutils
.css_color(context
.app
.theme
.background_color_rgb())
1574 self
.setBackgroundBrush(background_color
)
1581 hotkeys
.ZOOM_IN_SECONDARY
,
1584 qtutils
.add_action(self
, N_('Zoom Out'), self
.zoom_out
, hotkeys
.ZOOM_OUT
)
1586 qtutils
.add_action(self
, N_('Zoom to Fit'), self
.zoom_to_fit
, hotkeys
.FIT
)
1589 self
, N_('Select Parent'), self
._select
_parent
, hotkeys
.MOVE_DOWN_TERTIARY
1594 N_('Select Oldest Parent'),
1595 self
._select
_oldest
_parent
,
1600 self
, N_('Select Child'), self
._select
_child
, hotkeys
.MOVE_UP_TERTIARY
1604 self
, N_('Select Newest Child'), self
._select
_newest
_child
, hotkeys
.MOVE_UP
1609 self
.scene().clear()
1611 self
.x_offsets
.clear()
1615 # ViewerMixin interface
1616 def selected_items(self
):
1617 """Return the currently selected items"""
1618 return self
.scene().selectedItems()
1621 self
.scale_view(1.5)
1624 self
.scale_view(1.0 / 1.5)
1626 def selection_changed(self
):
1627 # Broadcast selection to other widgets
1628 selected_items
= self
.scene().selectedItems()
1629 commits
= sort_by_generation([item
.commit
for item
in selected_items
])
1630 self
.set_selecting(True)
1631 self
.commits_selected
.emit(commits
)
1632 self
.set_selecting(False)
1634 def select_commits(self
, commits
):
1637 with qtutils
.BlockSignals(self
.scene()):
1638 self
.select([commit
.oid
for commit
in commits
])
1640 def select(self
, oids
):
1641 """Select the item for the oids"""
1642 self
.scene().clearSelection()
1645 item
= self
.items
[oid
]
1648 item
.setSelected(True)
1649 item_rect
= item
.sceneTransform().mapRect(item
.boundingRect())
1650 self
.ensureVisible(item_rect
)
1652 def _get_item_by_generation(self
, commits
, criteria_fn
):
1653 """Return the item for the commit matching criteria"""
1657 for commit
in commits
:
1658 if generation
is None or criteria_fn(generation
, commit
.generation
):
1660 generation
= commit
.generation
1662 return self
.items
[oid
]
1666 def _oldest_item(self
, commits
):
1667 """Return the item for the commit with the oldest generation number"""
1668 return self
._get
_item
_by
_generation
(commits
, lambda a
, b
: a
> b
)
1670 def _newest_item(self
, commits
):
1671 """Return the item for the commit with the newest generation number"""
1672 return self
._get
_item
_by
_generation
(commits
, lambda a
, b
: a
< b
)
1674 def create_patch(self
):
1675 items
= self
.selected_items()
1678 context
= self
.context
1679 selected_commits
= sort_by_generation([n
.commit
for n
in items
])
1680 oids
= [c
.oid
for c
in selected_commits
]
1681 all_oids
= [c
.oid
for c
in self
.commits
]
1682 cmds
.do(cmds
.FormatPatch
, context
, oids
, all_oids
)
1684 def _select_parent(self
):
1685 """Select the parent with the newest generation number"""
1686 selected_item
= self
.selected_item()
1687 if selected_item
is None:
1689 parent_item
= self
._newest
_item
(selected_item
.commit
.parents
)
1690 if parent_item
is None:
1692 selected_item
.setSelected(False)
1693 parent_item
.setSelected(True)
1694 self
.ensureVisible(parent_item
.mapRectToScene(parent_item
.boundingRect()))
1696 def _select_oldest_parent(self
):
1697 """Select the parent with the oldest generation number"""
1698 selected_item
= self
.selected_item()
1699 if selected_item
is None:
1701 parent_item
= self
._oldest
_item
(selected_item
.commit
.parents
)
1702 if parent_item
is None:
1704 selected_item
.setSelected(False)
1705 parent_item
.setSelected(True)
1706 scene_rect
= parent_item
.mapRectToScene(parent_item
.boundingRect())
1707 self
.ensureVisible(scene_rect
)
1709 def _select_child(self
):
1710 """Select the child with the oldest generation number"""
1711 selected_item
= self
.selected_item()
1712 if selected_item
is None:
1714 child_item
= self
._oldest
_item
(selected_item
.commit
.children
)
1715 if child_item
is None:
1717 selected_item
.setSelected(False)
1718 child_item
.setSelected(True)
1719 scene_rect
= child_item
.mapRectToScene(child_item
.boundingRect())
1720 self
.ensureVisible(scene_rect
)
1722 def _select_newest_child(self
):
1723 """Select the Nth child with the newest generation number (N > 1)"""
1724 selected_item
= self
.selected_item()
1725 if selected_item
is None:
1727 if len(selected_item
.commit
.children
) > 1:
1728 children
= selected_item
.commit
.children
[1:]
1730 children
= selected_item
.commit
.children
1731 child_item
= self
._newest
_item
(children
)
1732 if child_item
is None:
1734 selected_item
.setSelected(False)
1735 child_item
.setSelected(True)
1736 scene_rect
= child_item
.mapRectToScene(child_item
.boundingRect())
1737 self
.ensureVisible(scene_rect
)
1739 def set_initial_view(self
):
1741 selected
= self
.selected_items()
1743 items
.extend(selected
)
1745 if not selected
and self
.commits
:
1746 commit
= self
.commits
[-1]
1747 items
.append(self
.items
[commit
.oid
])
1749 self
.setSceneRect(self
.scene().itemsBoundingRect())
1750 self
.fit_view_to_items(items
)
1752 def zoom_to_fit(self
):
1753 """Fit selected items into the viewport"""
1754 items
= self
.selected_items()
1755 self
.fit_view_to_items(items
)
1757 def fit_view_to_items(self
, items
):
1759 rect
= self
.scene().itemsBoundingRect()
1761 x_min
= y_min
= maxsize
1762 x_max
= y_max
= -maxsize
1768 x_min
= min(x_min
, x
)
1769 x_max
= max(x_max
, x
)
1770 y_min
= min(y_min
, y
)
1771 y_max
= max(y_max
, y
)
1773 rect
= QtCore
.QRectF(x_min
, y_min
, abs(x_max
- x_min
), abs(y_max
- y_min
))
1775 x_adjust
= abs(GraphView
.x_adjust
)
1776 y_adjust
= abs(GraphView
.y_adjust
)
1778 count
= max(2.0, 10.0 - len(items
) / 2.0)
1779 y_offset
= int(y_adjust
* count
)
1780 x_offset
= int(x_adjust
* count
)
1781 rect
.setX(rect
.x() - x_offset
// 2)
1782 rect
.setY(rect
.y() - y_adjust
// 2)
1783 rect
.setHeight(rect
.height() + y_offset
)
1784 rect
.setWidth(rect
.width() + x_offset
)
1786 self
.fitInView(rect
, Qt
.KeepAspectRatio
)
1787 self
.scene().invalidate()
1789 def handle_event(self
, event_handler
, event
, update
=True):
1790 event_handler(self
, event
)
1794 def set_selecting(self
, selecting
):
1795 self
.selecting
= selecting
1797 def pan(self
, event
):
1799 dx
= pos
.x() - self
.mouse_start
[0]
1800 dy
= pos
.y() - self
.mouse_start
[1]
1802 if dx
== 0 and dy
== 0:
1805 rect
= QtCore
.QRect(0, 0, abs(dx
), abs(dy
))
1806 delta
= self
.mapToScene(rect
).boundingRect()
1816 matrix
= self
.transform()
1818 matrix
*= self
.saved_matrix
1819 matrix
.translate(tx
, ty
)
1821 self
.setTransformationAnchor(QtWidgets
.QGraphicsView
.NoAnchor
)
1822 self
.setTransform(matrix
)
1824 def wheel_zoom(self
, event
):
1825 """Handle mouse wheel zooming."""
1826 delta
= qtcompat
.wheel_delta(event
)
1827 zoom
= math
.pow(2.0, delta
/ 512.0)
1831 .mapRect(QtCore
.QRectF(0.0, 0.0, 1.0, 1.0))
1834 if factor
< 0.014 or factor
> 42.0:
1836 self
.setTransformationAnchor(QtWidgets
.QGraphicsView
.AnchorUnderMouse
)
1838 self
.scale(zoom
, zoom
)
1840 def wheel_pan(self
, event
):
1841 """Handle mouse wheel panning."""
1842 unit
= QtCore
.QRectF(0.0, 0.0, 1.0, 1.0)
1843 factor
= 1.0 / self
.transform().mapRect(unit
).width()
1844 tx
, ty
= qtcompat
.wheel_translation(event
)
1846 matrix
= self
.transform().translate(tx
* factor
, ty
* factor
)
1847 self
.setTransformationAnchor(QtWidgets
.QGraphicsView
.NoAnchor
)
1848 self
.setTransform(matrix
)
1850 def scale_view(self
, scale
):
1853 .scale(scale
, scale
)
1854 .mapRect(QtCore
.QRectF(0, 0, 1, 1))
1857 if factor
< 0.07 or factor
> 100.0:
1861 adjust_scrollbars
= True
1862 scrollbar
= self
.verticalScrollBar()
1864 value
= get(scrollbar
)
1865 min_
= scrollbar
.minimum()
1866 max_
= scrollbar
.maximum()
1867 range_
= max_
- min_
1868 distance
= value
- min_
1869 nonzero_range
= range_
> 0.1
1871 scrolloffset
= distance
/ range_
1873 adjust_scrollbars
= False
1875 self
.setTransformationAnchor(QtWidgets
.QGraphicsView
.NoAnchor
)
1876 self
.scale(scale
, scale
)
1878 scrollbar
= self
.verticalScrollBar()
1879 if scrollbar
and adjust_scrollbars
:
1880 min_
= scrollbar
.minimum()
1881 max_
= scrollbar
.maximum()
1882 range_
= max_
- min_
1883 value
= min_
+ int(float(range_
) * scrolloffset
)
1884 scrollbar
.setValue(value
)
1886 def add_commits(self
, commits
):
1887 """Traverse commits and add them to the view."""
1888 self
.commits
.extend(commits
)
1889 scene
= self
.scene()
1890 for commit
in commits
:
1891 item
= Commit(commit
)
1892 self
.items
[commit
.oid
] = item
1893 for ref
in commit
.tags
:
1894 self
.items
[ref
] = item
1897 self
.layout_commits()
1900 def link(self
, commits
):
1901 """Create edges linking commits with their parents"""
1902 scene
= self
.scene()
1903 for commit
in commits
:
1905 commit_item
= self
.items
[commit
.oid
]
1907 # TODO - Handle truncated history viewing
1909 for parent
in reversed(commit
.parents
):
1911 parent_item
= self
.items
[parent
.oid
]
1913 # TODO - Handle truncated history viewing
1916 edge
= parent_item
.edges
[commit
.oid
]
1918 edge
= Edge(parent_item
, commit_item
)
1921 parent_item
.edges
[commit
.oid
] = edge
1922 commit_item
.edges
[parent
.oid
] = edge
1925 def layout_commits(self
):
1926 positions
= self
.position_nodes()
1928 # Each edge is accounted in two commits. Hence, accumulate invalid
1929 # edges to prevent double edge invalidation.
1930 invalid_edges
= set()
1932 for oid
, (x
, y
) in positions
.items():
1933 item
= self
.items
[oid
]
1939 for edge
in item
.edges
.values():
1940 invalid_edges
.add(edge
)
1942 for edge
in invalid_edges
:
1943 edge
.commits_were_invalidated()
1945 # Commit node layout technique
1947 # Nodes are aligned by a mesh. Columns and rows are distributed using
1948 # algorithms described below.
1950 # Row assignment algorithm
1952 # The algorithm aims consequent.
1953 # 1. A commit should be above all its parents.
1954 # 2. No commit should be at right side of a commit with a tag in same row.
1955 # This prevents overlapping of tag labels with commits and other labels.
1956 # 3. Commit density should be maximized.
1958 # The algorithm requires that all parents of a commit were assigned column.
1959 # Nodes must be traversed in generation ascend order. This guarantees that all
1960 # parents of a commit were assigned row. So, the algorithm may operate in
1961 # course of column assignment algorithm.
1963 # Row assignment uses frontier. A frontier is a dictionary that contains
1964 # minimum available row index for each column. It propagates during the
1965 # algorithm. Set of cells with tags is also maintained to meet second aim.
1967 # Initialization is performed by reset_rows method. Each new column should
1968 # be declared using declare_column method. Getting row for a cell is
1969 # implemented in alloc_cell method. Frontier must be propagated for any child
1970 # of fork commit which occupies different column. This meets first aim.
1972 # Column assignment algorithm
1974 # The algorithm traverses nodes in generation ascend order. This guarantees
1975 # that a node will be visited after all its parents.
1977 # The set of occupied columns are maintained during work. Initially it is
1978 # empty and no node occupied a column. Empty columns are allocated on demand.
1979 # Free index for column being allocated is searched in following way.
1980 # 1. Start from desired column and look towards graph center (0 column).
1981 # 2. Start from center and look in both directions simultaneously.
1982 # Desired column is defaulted to 0. Fork node should set desired column for
1983 # children equal to its one. This prevents branch from jumping too far from
1986 # Initialization is performed by reset_columns method. Column allocation is
1987 # implemented in alloc_column method. Initialization and main loop are in
1988 # recompute_grid method. The method also embeds row assignment algorithm by
1991 # Actions for each node are follow.
1992 # 1. If the node was not assigned a column then it is assigned empty one.
1994 # 3. Allocate columns for children.
1995 # If a child have a column assigned then it should no be overridden. One of
1996 # children is assigned same column as the node. If the node is a fork then the
1997 # child is chosen in generation descent order. This is a heuristic and it only
1998 # affects resulting appearance of the graph. Other children are assigned empty
1999 # columns in same order. It is the heuristic too.
2000 # 4. If no child occupies column of the node then leave it.
2001 # It is possible in consequent situations.
2002 # 4.1 The node is a leaf.
2003 # 4.2 The node is a fork and all its children are already assigned side
2004 # column. It is possible if all the children are merges.
2005 # 4.3 Single node child is a merge that is already assigned a column.
2006 # 5. Propagate frontier with respect to this node.
2007 # Each frontier entry corresponding to column occupied by any node's child
2008 # must be gather than node row index. This meets first aim of the row
2009 # assignment algorithm.
2010 # Note that frontier of child that occupies same row was propagated during
2011 # step 2. Hence, it must be propagated for children on side columns.
2013 def reset_columns(self
):
2014 # Some children of displayed commits might not be accounted in
2015 # 'commits' list. It is common case during loading of big graph.
2016 # But, they are assigned a column that must be reseted. Hence, use
2017 # depth-first traversal to reset all columns assigned.
2018 for node
in self
.commits
:
2019 if node
.column
is None:
2025 for child
in node
.children
:
2026 if child
.column
is not None:
2033 def reset_rows(self
):
2035 self
.tagged_cells
= set()
2037 def declare_column(self
, column
):
2039 # Align new column frontier by frontier of nearest column. If all
2040 # columns were left then select maximum frontier value.
2041 if not self
.columns
:
2042 self
.frontier
[column
] = max(list(self
.frontier
.values()))
2044 # This is heuristic that mostly affects roots. Note that the
2045 # frontier values for fork children will be overridden in course of
2046 # propagate_frontier.
2047 for offset
in itertools
.count(1):
2048 for c
in [column
+ offset
, column
- offset
]:
2049 if c
not in self
.columns
:
2050 # Column 'c' is not occupied.
2053 frontier
= self
.frontier
[c
]
2055 # Column 'c' was never allocated.
2059 # The frontier of the column may be higher because of
2060 # tag overlapping prevention performed for previous head.
2062 if self
.frontier
[column
] >= frontier
:
2067 self
.frontier
[column
] = frontier
2073 # First commit must be assigned 0 row.
2074 self
.frontier
[column
] = 0
2076 def alloc_column(self
, column
=0):
2077 columns
= self
.columns
2078 # First, look for free column by moving from desired column to graph
2079 # center (column 0).
2080 for c
in range(column
, 0, -1 if column
> 0 else 1):
2081 if c
not in columns
:
2082 if c
> self
.max_column
:
2084 elif c
< self
.min_column
:
2088 # If no free column was found between graph center and desired
2089 # column then look for free one by moving from center along both
2090 # directions simultaneously.
2091 for c
in itertools
.count(0):
2092 if c
not in columns
:
2093 if c
> self
.max_column
:
2097 if c
not in columns
:
2098 if c
< self
.min_column
:
2101 self
.declare_column(c
)
2105 def alloc_cell(self
, column
, tags
):
2106 # Get empty cell from frontier.
2107 cell_row
= self
.frontier
[column
]
2110 # Prevent overlapping of tag with cells already allocated a row.
2112 can_overlap
= list(range(column
+ 1, self
.max_column
+ 1))
2114 can_overlap
= list(range(column
- 1, self
.min_column
- 1, -1))
2115 for c
in can_overlap
:
2116 frontier
= self
.frontier
[c
]
2117 if frontier
> cell_row
:
2120 # Avoid overlapping with tags of commits at cell_row.
2122 can_overlap
= list(range(self
.min_column
, column
))
2124 can_overlap
= list(range(self
.max_column
, column
, -1))
2125 for cell_row
in itertools
.count(cell_row
):
2126 for c
in can_overlap
:
2127 if (c
, cell_row
) in self
.tagged_cells
:
2128 # Overlapping. Try next row.
2131 # No overlapping was found.
2133 # Note that all checks should be made for new cell_row value.
2136 self
.tagged_cells
.add((column
, cell_row
))
2138 # Propagate frontier.
2139 self
.frontier
[column
] = cell_row
+ 1
2142 def propagate_frontier(self
, column
, value
):
2143 current
= self
.frontier
[column
]
2145 self
.frontier
[column
] = value
2147 def leave_column(self
, column
):
2148 count
= self
.columns
[column
]
2150 del self
.columns
[column
]
2152 self
.columns
[column
] = count
- 1
2154 def recompute_grid(self
):
2155 self
.reset_columns()
2158 for node
in sort_by_generation(list(self
.commits
)):
2159 if node
.column
is None:
2160 # Node is either root or its parent is not in items. The last
2161 # happens when tree loading is in progress. Allocate new
2162 # columns for such nodes.
2163 node
.column
= self
.alloc_column()
2165 node
.row
= self
.alloc_cell(node
.column
, node
.tags
)
2167 # Allocate columns for children which are still without one. Also
2168 # propagate frontier for children.
2170 sorted_children
= sorted(
2171 node
.children
, key
=lambda c
: c
.generation
, reverse
=True
2173 citer
= iter(sorted_children
)
2175 if child
.column
is None:
2176 # Top most child occupies column of parent.
2177 child
.column
= node
.column
2178 # Note that frontier is propagated in course of
2181 self
.propagate_frontier(child
.column
, node
.row
+ 1)
2183 # No child occupies same column.
2184 self
.leave_column(node
.column
)
2185 # Note that the loop below will pass no iteration.
2187 # Rest children are allocated new column.
2189 if child
.column
is None:
2190 child
.column
= self
.alloc_column(node
.column
)
2191 self
.propagate_frontier(child
.column
, node
.row
+ 1)
2193 child
= node
.children
[0]
2194 if child
.column
is None:
2195 child
.column
= node
.column
2196 # Note that frontier is propagated in course of alloc_cell.
2197 elif child
.column
!= node
.column
:
2198 # Child node have other parents and occupies column of one
2200 self
.leave_column(node
.column
)
2201 # But frontier must be propagated with respect to this
2203 self
.propagate_frontier(child
.column
, node
.row
+ 1)
2205 # This is a leaf node.
2206 self
.leave_column(node
.column
)
2208 def position_nodes(self
):
2209 self
.recompute_grid()
2211 x_start
= self
.x_start
2218 for node
in self
.commits
:
2219 x_pos
= x_start
+ node
.column
* x_off
2220 y_pos
= y_off
+ node
.row
* y_off
2222 positions
[node
.oid
] = (x_pos
, y_pos
)
2223 x_min
= min(x_min
, x_pos
)
2230 def contextMenuEvent(self
, event
):
2231 self
.context_menu_event(event
)
2233 def mousePressEvent(self
, event
):
2234 if event
.button() == Qt
.MidButton
:
2236 self
.mouse_start
= [pos
.x(), pos
.y()]
2237 self
.saved_matrix
= self
.transform()
2238 self
.is_panning
= True
2240 if event
.button() == Qt
.RightButton
:
2243 if event
.button() == Qt
.LeftButton
:
2245 self
.handle_event(QtWidgets
.QGraphicsView
.mousePressEvent
, event
)
2247 def mouseMoveEvent(self
, event
):
2251 pos
= self
.mapToScene(event
.pos())
2252 self
.last_mouse
[0] = pos
.x()
2253 self
.last_mouse
[1] = pos
.y()
2254 self
.handle_event(QtWidgets
.QGraphicsView
.mouseMoveEvent
, event
, update
=False)
2256 def mouseReleaseEvent(self
, event
):
2257 self
.pressed
= False
2258 if event
.button() == Qt
.MidButton
:
2259 self
.is_panning
= False
2261 self
.handle_event(QtWidgets
.QGraphicsView
.mouseReleaseEvent
, event
)
2262 self
.viewport().repaint()
2264 def wheelEvent(self
, event
):
2265 """Handle Qt mouse wheel events."""
2266 if event
.modifiers() & Qt
.ControlModifier
:
2267 self
.wheel_zoom(event
)
2269 self
.wheel_pan(event
)
2271 def fitInView(self
, rect
, flags
=Qt
.IgnoreAspectRatio
):
2272 """Override fitInView to remove unwanted margins
2274 https://bugreports.qt.io/browse/QTBUG-42331 - based on QT sources
2277 if self
.scene() is None or rect
.isNull():
2279 unity
= self
.transform().mapRect(QtCore
.QRectF(0, 0, 1, 1))
2280 self
.scale(1.0 / unity
.width(), 1.0 / unity
.height())
2281 view_rect
= self
.viewport().rect()
2282 scene_rect
= self
.transform().mapRect(rect
)
2283 xratio
= view_rect
.width() / scene_rect
.width()
2284 yratio
= view_rect
.height() / scene_rect
.height()
2285 if flags
== Qt
.KeepAspectRatio
:
2286 xratio
= yratio
= min(xratio
, yratio
)
2287 elif flags
== Qt
.KeepAspectRatioByExpanding
:
2288 xratio
= yratio
= max(xratio
, yratio
)
2289 self
.scale(xratio
, yratio
)
2290 self
.centerOn(rect
.center())
2293 def sort_by_generation(commits
):
2294 if len(commits
) < 2:
2296 commits
.sort(key
=lambda x
: x
.generation
)
2302 # oid -- Git objects IDs (i.e. SHA-1 IDs)
2303 # ref -- Git references that resolve to a commit-ish (HEAD, branches, tags)