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 func
= getattr(widget
, name
)
77 func
= getattr(self
.default
, name
)
79 return func(*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
, func
):
118 """Run an operation with a commit object ID"""
119 oid
= self
.clicked_oid()
126 def with_selected_oid(self
, func
):
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(
292 has_single_selection_or_clicked
294 self
.menu_actions
['cherry_pick'].setEnabled(has_single_selection_or_clicked
)
295 self
.menu_actions
['copy'].setEnabled(has_single_selection_or_clicked
)
296 self
.menu_actions
['create_branch'].setEnabled(has_single_selection_or_clicked
)
297 self
.menu_actions
['create_patch'].setEnabled(has_selection
)
298 self
.menu_actions
['create_tag'].setEnabled(has_single_selection_or_clicked
)
299 self
.menu_actions
['create_tarball'].setEnabled(has_single_selection_or_clicked
)
300 self
.menu_actions
['rebase_to_commit'].setEnabled(
301 has_single_selection_or_clicked
303 self
.menu_actions
['reset_mixed'].setEnabled(has_single_selection_or_clicked
)
304 self
.menu_actions
['reset_keep'].setEnabled(has_single_selection_or_clicked
)
305 self
.menu_actions
['reset_merge'].setEnabled(has_single_selection_or_clicked
)
306 self
.menu_actions
['reset_soft'].setEnabled(has_single_selection_or_clicked
)
307 self
.menu_actions
['reset_hard'].setEnabled(has_single_selection_or_clicked
)
308 self
.menu_actions
['restore_worktree'].setEnabled(
309 has_single_selection_or_clicked
311 self
.menu_actions
['revert'].setEnabled(has_single_selection_or_clicked
)
312 self
.menu_actions
['save_blob'].setEnabled(has_single_selection_or_clicked
)
314 def context_menu_event(self
, event
):
315 """Build a context menu and execute it"""
316 self
.update_menu_actions(event
)
317 menu
= qtutils
.create_menu(N_('Actions'), self
)
318 menu
.addAction(self
.menu_actions
['diff_this_selected'])
319 menu
.addAction(self
.menu_actions
['diff_selected_this'])
320 menu
.addAction(self
.menu_actions
['diff_commit'])
321 menu
.addAction(self
.menu_actions
['diff_commit_all'])
323 menu
.addAction(self
.menu_actions
['checkout_branch'])
324 menu
.addAction(self
.menu_actions
['create_branch'])
325 menu
.addAction(self
.menu_actions
['create_tag'])
326 menu
.addAction(self
.menu_actions
['rebase_to_commit'])
328 menu
.addAction(self
.menu_actions
['cherry_pick'])
329 menu
.addAction(self
.menu_actions
['revert'])
330 menu
.addAction(self
.menu_actions
['create_patch'])
331 menu
.addAction(self
.menu_actions
['create_tarball'])
333 reset_menu
= menu
.addMenu(N_('Reset'))
334 reset_menu
.addAction(self
.menu_actions
['reset_soft'])
335 reset_menu
.addAction(self
.menu_actions
['reset_mixed'])
336 reset_menu
.addAction(self
.menu_actions
['restore_worktree'])
337 reset_menu
.addSeparator()
338 reset_menu
.addAction(self
.menu_actions
['reset_keep'])
339 reset_menu
.addAction(self
.menu_actions
['reset_merge'])
340 reset_menu
.addAction(self
.menu_actions
['reset_hard'])
341 menu
.addAction(self
.menu_actions
['checkout_detached'])
343 menu
.addAction(self
.menu_actions
['save_blob'])
344 menu
.addAction(self
.menu_actions
['copy'])
345 menu
.exec_(self
.mapToGlobal(event
.pos()))
348 def set_icon(icon
, action
):
349 """ "Set the icon for an action and return the action"""
354 def viewer_actions(widget
):
355 """Return commont actions across the tree and graph widgets"""
357 'diff_this_selected': set_icon(
360 widget
, N_('Diff this -> selected'), widget
.proxy
.diff_this_selected
363 'diff_selected_this': set_icon(
366 widget
, N_('Diff selected -> this'), widget
.proxy
.diff_selected_this
369 'create_branch': set_icon(
371 qtutils
.add_action(widget
, N_('Create Branch'), widget
.proxy
.create_branch
),
373 'create_patch': set_icon(
375 qtutils
.add_action(widget
, N_('Create Patch'), widget
.proxy
.create_patch
),
377 'create_tag': set_icon(
379 qtutils
.add_action(widget
, N_('Create Tag'), widget
.proxy
.create_tag
),
381 'create_tarball': set_icon(
384 widget
, N_('Save As Tarball/Zip...'), widget
.proxy
.create_tarball
387 'cherry_pick': set_icon(
389 qtutils
.add_action(widget
, N_('Cherry Pick'), widget
.proxy
.cherry_pick
),
392 icons
.undo(), qtutils
.add_action(widget
, N_('Revert'), widget
.proxy
.revert
)
394 'diff_commit': set_icon(
397 widget
, N_('Launch Diff Tool'), widget
.proxy
.show_diff
, hotkeys
.DIFF
400 'diff_commit_all': set_icon(
404 N_('Launch Directory Diff Tool'),
405 widget
.proxy
.show_dir_diff
,
406 hotkeys
.DIFF_SECONDARY
,
409 'checkout_branch': set_icon(
412 widget
, N_('Checkout Branch'), widget
.proxy
.checkout_branch
415 'checkout_detached': qtutils
.add_action(
416 widget
, N_('Checkout Detached HEAD'), widget
.proxy
.checkout_detached
418 'rebase_to_commit': set_icon(
421 widget
, N_('Rebase to this commit'), widget
.proxy
.rebase_to_commit
424 'reset_soft': set_icon(
425 icons
.style_dialog_reset(),
427 widget
, N_('Reset Branch (Soft)'), widget
.proxy
.reset_soft
430 'reset_mixed': set_icon(
431 icons
.style_dialog_reset(),
433 widget
, N_('Reset Branch and Stage (Mixed)'), widget
.proxy
.reset_mixed
436 'reset_keep': set_icon(
437 icons
.style_dialog_reset(),
440 N_('Restore Worktree and Reset All (Keep Unstaged Edits)'),
441 widget
.proxy
.reset_keep
,
444 'reset_merge': set_icon(
445 icons
.style_dialog_reset(),
448 N_('Restore Worktree and Reset All (Merge)'),
449 widget
.proxy
.reset_merge
,
452 'reset_hard': set_icon(
453 icons
.style_dialog_reset(),
456 N_('Restore Worktree and Reset All (Hard)'),
457 widget
.proxy
.reset_hard
,
460 'restore_worktree': set_icon(
463 widget
, N_('Restore Worktree'), widget
.proxy
.restore_worktree
466 'save_blob': set_icon(
469 widget
, N_('Grab File...'), widget
.proxy
.save_blob_dialog
477 widget
.proxy
.copy_to_clipboard
,
484 class CommitTreeWidgetItem(QtWidgets
.QTreeWidgetItem
):
485 """Custom TreeWidgetItem used in to build the commit tree widget"""
487 def __init__(self
, commit
, parent
=None):
488 QtWidgets
.QTreeWidgetItem
.__init
__(self
, parent
)
490 self
.setText(0, commit
.summary
)
491 self
.setText(1, commit
.author
)
492 self
.setText(2, commit
.authdate
)
495 # pylint: disable=too-many-ancestors
496 class CommitTreeWidget(standard
.TreeWidget
, ViewerMixin
):
497 """Display commits using a flat treewidget in "list" mode"""
499 commits_selected
= Signal(object)
500 diff_commits
= Signal(object, object)
501 zoom_to_fit
= Signal()
503 def __init__(self
, context
, parent
):
504 standard
.TreeWidget
.__init
__(self
, parent
)
505 ViewerMixin
.__init
__(self
)
507 self
.setSelectionMode(self
.ExtendedSelection
)
508 self
.setHeaderLabels([N_('Summary'), N_('Author'), N_('Date, Time')])
510 self
.context
= context
512 self
.menu_actions
= None
513 self
.selecting
= False
515 self
._adjust
_columns
= False
517 self
.action_up
= qtutils
.add_action(
518 self
, N_('Go Up'), self
.go_up
, hotkeys
.MOVE_UP
521 self
.action_down
= qtutils
.add_action(
522 self
, N_('Go Down'), self
.go_down
, hotkeys
.MOVE_DOWN
525 self
.zoom_to_fit_action
= qtutils
.add_action(
526 self
, N_('Zoom to Fit'), self
.zoom_to_fit
.emit
, hotkeys
.FIT
529 # pylint: disable=no-member
530 self
.itemSelectionChanged
.connect(self
.selection_changed
)
532 def export_state(self
):
533 """Export the widget's state"""
534 # The base class method is intentionally overridden because we only
535 # care about the details below for this subwidget.
537 state
['column_widths'] = self
.column_widths()
540 def apply_state(self
, state
):
541 """Apply the exported widget state"""
543 column_widths
= state
['column_widths']
544 except (KeyError, ValueError):
547 self
.set_column_widths(column_widths
)
549 # Defer showing the columns until we are shown, and our true width
550 # is known. Calling adjust_columns() here ends up with the wrong
551 # answer because we have not yet been parented to the layout.
552 # We set this flag that we process once during our initial
554 self
._adjust
_columns
= True
558 def showEvent(self
, event
):
559 """Override QWidget::showEvent() to size columns when we are shown"""
560 if self
._adjust
_columns
:
561 self
._adjust
_columns
= False
563 two_thirds
= (width
* 2) // 3
564 one_sixth
= width
// 6
566 self
.setColumnWidth(0, two_thirds
)
567 self
.setColumnWidth(1, one_sixth
)
568 self
.setColumnWidth(2, one_sixth
)
569 return standard
.TreeWidget
.showEvent(self
, event
)
573 """Select the item above the current item"""
574 self
.goto(self
.itemAbove
)
577 """Select the item below the current item"""
578 self
.goto(self
.itemBelow
)
580 def goto(self
, finder
):
581 """Move the selection using a finder strategy"""
582 items
= self
.selected_items()
583 item
= items
[0] if items
else None
588 self
.select([found
.commit
.oid
])
590 def selected_commit_range(self
):
591 """Return a range of selected commits"""
592 selected_items
= self
.selected_items()
593 if not selected_items
:
595 return selected_items
[-1].commit
.oid
, selected_items
[0].commit
.oid
597 def set_selecting(self
, selecting
):
598 """Record the "are we selecting?" status"""
599 self
.selecting
= selecting
601 def selection_changed(self
):
602 """Respond to itemSelectionChanged notifications"""
603 items
= self
.selected_items()
605 self
.set_selecting(True)
606 self
.commits_selected
.emit([])
607 self
.set_selecting(False)
609 self
.set_selecting(True)
610 self
.commits_selected
.emit(sort_by_generation([i
.commit
for i
in items
]))
611 self
.set_selecting(False)
613 def select_commits(self
, commits
):
614 """Select commits that were selected by the sibling tree/graph widget"""
617 with qtutils
.BlockSignals(self
):
618 self
.select([commit
.oid
for commit
in commits
])
620 def select(self
, oids
):
621 """Mark items as selected"""
622 self
.clearSelection()
627 item
= self
.oidmap
[oid
]
630 self
.scrollToItem(item
)
631 item
.setSelected(True)
635 QtWidgets
.QTreeWidget
.clear(self
)
639 def add_commits(self
, commits
):
640 """Add commits to the tree"""
641 self
.commits
.extend(commits
)
643 for c
in reversed(commits
):
644 item
= CommitTreeWidgetItem(c
)
646 self
.oidmap
[c
.oid
] = item
648 self
.oidmap
[tag
] = item
649 self
.insertTopLevelItems(0, items
)
651 def create_patch(self
):
652 """Export a patch from the selected items"""
653 items
= self
.selectedItems()
656 context
= self
.context
657 oids
= [item
.commit
.oid
for item
in reversed(items
)]
658 all_oids
= [c
.oid
for c
in self
.commits
]
659 cmds
.do(cmds
.FormatPatch
, context
, oids
, all_oids
)
662 def contextMenuEvent(self
, event
):
663 """Create a custom context menu and execute it"""
664 self
.context_menu_event(event
)
666 def mousePressEvent(self
, event
):
667 """Intercept the right-click event to retain selection state"""
668 item
= self
.itemAt(event
.pos())
672 self
.clicked
= item
.commit
673 if event
.button() == Qt
.RightButton
:
676 QtWidgets
.QTreeWidget
.mousePressEvent(self
, event
)
679 class GitDAG(standard
.MainWindow
):
680 """The git-dag widget."""
682 commits_selected
= Signal(object)
684 def __init__(self
, context
, params
, parent
=None):
685 super(GitDAG
, self
).__init
__(parent
)
687 self
.setMinimumSize(420, 420)
689 # change when widgets are added/removed
690 self
.widget_version
= 2
691 self
.context
= context
693 self
.model
= context
.model
696 self
.commit_list
= []
698 self
.old_refs
= set()
701 self
.force_refresh
= False
704 self
.revtext
= completion
.GitLogLineEdit(context
)
705 self
.maxresults
= standard
.SpinBox()
707 self
.zoom_out
= qtutils
.create_action_button(
708 tooltip
=N_('Zoom Out'), icon
=icons
.zoom_out()
711 self
.zoom_in
= qtutils
.create_action_button(
712 tooltip
=N_('Zoom In'), icon
=icons
.zoom_in()
715 self
.zoom_to_fit
= qtutils
.create_action_button(
716 tooltip
=N_('Zoom to Fit'), icon
=icons
.zoom_fit_best()
719 self
.treewidget
= CommitTreeWidget(context
, self
)
720 self
.diffwidget
= diff
.DiffWidget(context
, self
, is_commit
=True)
721 self
.filewidget
= filelist
.FileWidget(context
, self
)
722 self
.graphview
= GraphView(context
, self
)
724 self
.treewidget
.commits_selected
.connect(self
.commits_selected
)
725 self
.graphview
.commits_selected
.connect(self
.commits_selected
)
727 self
.commits_selected
.connect(self
.select_commits
)
728 self
.commits_selected
.connect(self
.diffwidget
.commits_selected
)
729 self
.commits_selected
.connect(self
.filewidget
.commits_selected
)
730 self
.commits_selected
.connect(self
.graphview
.select_commits
)
731 self
.commits_selected
.connect(self
.treewidget
.select_commits
)
733 self
.filewidget
.files_selected
.connect(self
.diffwidget
.files_selected
)
734 self
.filewidget
.difftool_selected
.connect(self
.difftool_selected
)
735 self
.filewidget
.histories_selected
.connect(self
.histories_selected
)
737 self
.proxy
= FocusRedirectProxy(
738 self
.treewidget
, self
.graphview
, self
.filewidget
741 self
.viewer_actions
= actions
= viewer_actions(self
)
742 self
.treewidget
.menu_actions
= actions
743 self
.graphview
.menu_actions
= actions
745 self
.controls_layout
= qtutils
.hbox(
746 defs
.no_margin
, defs
.spacing
, self
.revtext
, self
.maxresults
749 self
.controls_widget
= QtWidgets
.QWidget()
750 self
.controls_widget
.setLayout(self
.controls_layout
)
752 self
.log_dock
= qtutils
.create_dock('Log', N_('Log'), self
, stretch
=False)
753 self
.log_dock
.setWidget(self
.treewidget
)
754 log_dock_titlebar
= self
.log_dock
.titleBarWidget()
755 log_dock_titlebar
.add_corner_widget(self
.controls_widget
)
757 self
.file_dock
= qtutils
.create_dock('Files', N_('Files'), self
)
758 self
.file_dock
.setWidget(self
.filewidget
)
760 self
.diff_options
= diff
.Options(self
.diffwidget
)
761 self
.diffwidget
.set_options(self
.diff_options
)
762 self
.diff_options
.hide_advanced_options()
763 self
.diff_options
.set_diff_type(main
.Types
.TEXT
)
765 self
.diff_dock
= qtutils
.create_dock('Diff', N_('Diff'), self
)
766 self
.diff_dock
.setWidget(self
.diffwidget
)
768 diff_titlebar
= self
.diff_dock
.titleBarWidget()
769 diff_titlebar
.add_corner_widget(self
.diff_options
)
771 self
.graph_controls_layout
= qtutils
.hbox(
780 self
.graph_controls_widget
= QtWidgets
.QWidget()
781 self
.graph_controls_widget
.setLayout(self
.graph_controls_layout
)
783 self
.graphview_dock
= qtutils
.create_dock('Graph', N_('Graph'), self
)
784 self
.graphview_dock
.setWidget(self
.graphview
)
785 graph_titlebar
= self
.graphview_dock
.titleBarWidget()
786 graph_titlebar
.add_corner_widget(self
.graph_controls_widget
)
788 self
.lock_layout_action
= qtutils
.add_action_bool(
789 self
, N_('Lock Layout'), self
.set_lock_layout
, False
792 self
.refresh_action
= qtutils
.add_action(
793 self
, N_('Refresh'), self
.refresh
, hotkeys
.REFRESH
796 # Create the application menu
797 self
.menubar
= QtWidgets
.QMenuBar(self
)
798 self
.setMenuBar(self
.menubar
)
801 self
.view_menu
= qtutils
.add_menu(N_('View'), self
.menubar
)
802 self
.view_menu
.addAction(self
.refresh_action
)
803 self
.view_menu
.addAction(self
.log_dock
.toggleViewAction())
804 self
.view_menu
.addAction(self
.graphview_dock
.toggleViewAction())
805 self
.view_menu
.addAction(self
.diff_dock
.toggleViewAction())
806 self
.view_menu
.addAction(self
.file_dock
.toggleViewAction())
807 self
.view_menu
.addSeparator()
808 self
.view_menu
.addAction(self
.lock_layout_action
)
810 left
= Qt
.LeftDockWidgetArea
811 right
= Qt
.RightDockWidgetArea
812 self
.addDockWidget(left
, self
.log_dock
)
813 self
.addDockWidget(left
, self
.diff_dock
)
814 self
.addDockWidget(right
, self
.graphview_dock
)
815 self
.addDockWidget(right
, self
.file_dock
)
817 # Also re-loads dag.* from the saved state
818 self
.init_state(context
.settings
, self
.resize_to_desktop
)
820 qtutils
.connect_button(self
.zoom_out
, self
.graphview
.zoom_out
)
821 qtutils
.connect_button(self
.zoom_in
, self
.graphview
.zoom_in
)
822 qtutils
.connect_button(self
.zoom_to_fit
, self
.graphview
.zoom_to_fit
)
824 self
.treewidget
.zoom_to_fit
.connect(self
.graphview
.zoom_to_fit
)
825 self
.treewidget
.diff_commits
.connect(self
.diff_commits
)
826 self
.graphview
.diff_commits
.connect(self
.diff_commits
)
827 self
.filewidget
.grab_file
.connect(self
.grab_file
)
829 # pylint: disable=no-member
830 self
.maxresults
.editingFinished
.connect(self
.display
)
832 self
.revtext
.textChanged
.connect(self
.text_changed
)
833 self
.revtext
.activated
.connect(self
.display
)
834 self
.revtext
.enter
.connect(self
.display
)
835 self
.revtext
.down
.connect(self
.focus_tree
)
837 # The model is updated in another thread so use
838 # signals/slots to bring control back to the main GUI thread
839 self
.model
.updated
.connect(self
.model_updated
, type=Qt
.QueuedConnection
)
841 qtutils
.add_action(self
, 'FocusInput', self
.focus_input
, hotkeys
.FOCUS_INPUT
)
842 qtutils
.add_action(self
, 'FocusTree', self
.focus_tree
, hotkeys
.FOCUS_TREE
)
843 qtutils
.add_action(self
, 'FocusDiff', self
.focus_diff
, hotkeys
.FOCUS_DIFF
)
844 qtutils
.add_close_action(self
)
846 self
.set_params(params
)
848 def set_params(self
, params
):
849 context
= self
.context
852 # Update fields affected by model
853 self
.revtext
.setText(params
.ref
)
854 self
.maxresults
.setValue(params
.count
)
855 self
.update_window_title()
857 if self
.thread
is not None:
860 self
.thread
= ReaderThread(context
, params
, self
)
863 thread
.begin
.connect(self
.thread_begin
, type=Qt
.QueuedConnection
)
864 thread
.status
.connect(self
.thread_status
, type=Qt
.QueuedConnection
)
865 thread
.add
.connect(self
.add_commits
, type=Qt
.QueuedConnection
)
866 thread
.end
.connect(self
.thread_end
, type=Qt
.QueuedConnection
)
868 def focus_input(self
):
869 """Focus the revision input field"""
870 self
.revtext
.setFocus()
872 def focus_tree(self
):
873 """Focus the revision tree list widget"""
874 self
.treewidget
.setFocus()
876 def focus_diff(self
):
877 """Focus the diff widget"""
878 self
.diffwidget
.setFocus()
880 def text_changed(self
, txt
):
881 self
.params
.ref
= txt
882 self
.update_window_title()
884 def update_window_title(self
):
885 project
= self
.model
.project
888 N_('%(project)s: %(ref)s - DAG')
889 % dict(project
=project
, ref
=self
.params
.ref
)
892 self
.setWindowTitle(project
+ N_(' - DAG'))
894 def export_state(self
):
895 state
= standard
.MainWindow
.export_state(self
)
896 state
['count'] = self
.params
.count
897 state
['log'] = self
.treewidget
.export_state()
898 state
['word_wrap'] = self
.diffwidget
.options
.enable_word_wrapping
.isChecked()
901 def apply_state(self
, state
):
902 result
= standard
.MainWindow
.apply_state(self
, state
)
904 count
= state
['count']
905 if self
.params
.overridden('count'):
906 count
= self
.params
.count
907 except (KeyError, TypeError, ValueError, AttributeError):
908 count
= self
.params
.count
910 self
.params
.set_count(count
)
911 self
.lock_layout_action
.setChecked(state
.get('lock_layout', False))
912 self
.diffwidget
.set_word_wrapping(state
.get('word_wrap', False), update
=True)
915 log_state
= state
['log']
916 except (KeyError, ValueError):
919 self
.treewidget
.apply_state(log_state
)
923 def model_updated(self
):
925 self
.update_window_title()
928 """Unconditionally refresh the DAG"""
929 # self.force_refresh triggers an Unconditional redraw
930 self
.force_refresh
= True
931 cmds
.do(cmds
.Refresh
, self
.context
)
932 self
.force_refresh
= False
935 """Update the view when the Git refs change"""
936 ref
= get(self
.revtext
)
937 count
= get(self
.maxresults
)
938 context
= self
.context
940 # The DAG tries to avoid updating when the object IDs have not
941 # changed. Without doing this the DAG constantly redraws itself
942 # whenever inotify sends update events, which hurts usability.
944 # To minimize redraws we leverage `git rev-parse`. The strategy is to
945 # use `git rev-parse` on the input line, which converts each argument
946 # into object IDs. From there it's a simple matter of detecting when
947 # the object IDs changed.
949 # In addition to object IDs, we also need to know when the set of
950 # named references (branches, tags) changes so that an update is
951 # triggered when new branches and tags are created.
952 refs
= set(model
.local_branches
+ model
.remote_branches
+ model
.tags
)
953 argv
= utils
.shell_split(ref
or 'HEAD')
954 oids
= gitcmds
.parse_refs(context
, argv
)
957 or count
!= self
.old_count
958 or oids
!= self
.old_oids
959 or refs
!= self
.old_refs
963 self
.params
.set_ref(ref
)
964 self
.params
.set_count(count
)
968 self
.old_count
= count
971 def select_commits(self
, commits
):
972 self
.selection
= commits
976 self
.commit_list
= []
977 self
.graphview
.clear()
978 self
.treewidget
.clear()
980 def add_commits(self
, commits
):
981 self
.commit_list
.extend(commits
)
982 # Keep track of commits
983 for commit_obj
in commits
:
984 self
.commits
[commit_obj
.oid
] = commit_obj
985 for tag
in commit_obj
.tags
:
986 self
.commits
[tag
] = commit_obj
987 self
.graphview
.add_commits(commits
)
988 self
.treewidget
.add_commits(commits
)
990 def thread_begin(self
):
993 def thread_end(self
):
994 self
.restore_selection()
996 def thread_status(self
, successful
):
997 self
.revtext
.hint
.set_error(not successful
)
999 def restore_selection(self
):
1000 selection
= self
.selection
1002 commit_obj
= self
.commit_list
[-1]
1004 # No commits, exist, early-out
1007 new_commits
= [self
.commits
.get(s
.oid
, None) for s
in selection
]
1008 new_commits
= [c
for c
in new_commits
if c
is not None]
1010 # The old selection exists in the new state
1011 self
.commits_selected
.emit(sort_by_generation(new_commits
))
1013 # The old selection is now empty. Select the top-most commit
1014 self
.commits_selected
.emit([commit_obj
])
1016 self
.graphview
.set_initial_view()
1018 def diff_commits(self
, left
, right
):
1019 paths
= self
.params
.paths()
1021 cmds
.difftool_launch(self
.context
, left
=left
, right
=right
, paths
=paths
)
1023 difftool
.diff_commits(self
.context
, self
, left
, right
)
1026 def closeEvent(self
, event
):
1027 self
.revtext
.close_popup()
1029 standard
.MainWindow
.closeEvent(self
, event
)
1031 def histories_selected(self
, histories
):
1032 argv
= [self
.model
.currentbranch
, '--']
1033 argv
.extend(histories
)
1034 text
= core
.list2cmdline(argv
)
1035 self
.revtext
.setText(text
)
1038 def difftool_selected(self
, files
):
1039 bottom
, top
= self
.treewidget
.selected_commit_range()
1042 cmds
.difftool_launch(
1043 self
.context
, left
=bottom
, left_take_parent
=True, right
=top
, paths
=files
1046 def grab_file(self
, filename
):
1047 """Save the selected file from the filelist widget"""
1048 oid
= self
.treewidget
.selected_oid()
1049 model
= browse
.BrowseModel(oid
, filename
=filename
)
1050 browse
.save_path(self
.context
, filename
, model
)
1053 class ReaderThread(QtCore
.QThread
):
1055 add
= Signal(object)
1057 status
= Signal(object)
1059 def __init__(self
, context
, params
, parent
):
1060 QtCore
.QThread
.__init
__(self
, parent
)
1061 self
.context
= context
1062 self
.params
= params
1065 self
._mutex
= QtCore
.QMutex()
1066 self
._condition
= QtCore
.QWaitCondition()
1069 context
= self
.context
1070 repo
= dag
.RepoReader(context
, self
.params
)
1074 for commit
in repo
.get():
1077 self
._condition
.wait(self
._mutex
)
1078 self
._mutex
.unlock()
1082 commits
.append(commit
)
1083 if len(commits
) >= 512:
1084 self
.add
.emit(commits
)
1087 self
.status
.emit(repo
.returncode
== 0)
1089 self
.add
.emit(commits
)
1095 QtCore
.QThread
.start(self
)
1100 self
._mutex
.unlock()
1105 self
._mutex
.unlock()
1106 self
._condition
.wakeOne()
1113 class Cache(object):
1118 def label_font(cls
):
1119 font
= cls
._label
_font
1121 font
= cls
._label
_font
= QtWidgets
.QApplication
.font()
1122 font
.setPointSize(6)
1126 class Edge(QtWidgets
.QGraphicsItem
):
1127 item_type
= qtutils
.standard_item_type_value(1)
1129 def __init__(self
, source
, dest
):
1131 QtWidgets
.QGraphicsItem
.__init
__(self
)
1133 self
.setAcceptedMouseButtons(Qt
.NoButton
)
1134 self
.source
= source
1136 self
.commit
= source
.commit
1139 self
.recompute_bound()
1141 self
.path_valid
= False
1143 # Choose a new color for new branch edges
1144 if self
.source
.x() < self
.dest
.x():
1145 color
= EdgeColor
.cycle()
1147 elif self
.source
.x() != self
.dest
.x():
1148 color
= EdgeColor
.current()
1151 color
= EdgeColor
.current()
1154 self
.pen
= QtGui
.QPen(color
, 4.0, line
, Qt
.SquareCap
, Qt
.RoundJoin
)
1156 def recompute_bound(self
):
1157 dest_pt
= Commit
.item_bbox
.center()
1159 self
.source_pt
= self
.mapFromItem(self
.source
, dest_pt
)
1160 self
.dest_pt
= self
.mapFromItem(self
.dest
, dest_pt
)
1161 self
.line
= QtCore
.QLineF(self
.source_pt
, self
.dest_pt
)
1163 width
= self
.dest_pt
.x() - self
.source_pt
.x()
1164 height
= self
.dest_pt
.y() - self
.source_pt
.y()
1165 rect
= QtCore
.QRectF(self
.source_pt
, QtCore
.QSizeF(width
, height
))
1166 self
.bound
= rect
.normalized()
1168 def commits_were_invalidated(self
):
1169 self
.recompute_bound()
1170 self
.prepareGeometryChange()
1171 # The path should not be recomputed immediately because just small part
1172 # of DAG is actually shown at same time. It will be recomputed on
1173 # demand in course of 'paint' method.
1174 self
.path_valid
= False
1175 # Hence, just queue redrawing.
1180 return self
.item_type
1182 def boundingRect(self
):
1185 def recompute_path(self
):
1186 QRectF
= QtCore
.QRectF
1187 QPointF
= QtCore
.QPointF
1190 connector_length
= 5
1192 path
= QtGui
.QPainterPath()
1194 if self
.source
.x() == self
.dest
.x():
1195 path
.moveTo(self
.source
.x(), self
.source
.y())
1196 path
.lineTo(self
.dest
.x(), self
.dest
.y())
1198 # Define points starting from source
1199 point1
= QPointF(self
.source
.x(), self
.source
.y())
1200 point2
= QPointF(point1
.x(), point1
.y() - connector_length
)
1201 point3
= QPointF(point2
.x() + arc_rect
, point2
.y() - arc_rect
)
1203 # Define points starting from dest
1204 point4
= QPointF(self
.dest
.x(), self
.dest
.y())
1205 point5
= QPointF(point4
.x(), point3
.y() - arc_rect
)
1206 point6
= QPointF(point5
.x() - arc_rect
, point5
.y() + arc_rect
)
1208 start_angle_arc1
= 180
1209 span_angle_arc1
= 90
1210 start_angle_arc2
= 90
1211 span_angle_arc2
= -90
1213 # If the dest is at the left of the source, then we
1214 # need to reverse some values
1215 if self
.source
.x() > self
.dest
.x():
1216 point3
= QPointF(point2
.x() - arc_rect
, point3
.y())
1217 point6
= QPointF(point5
.x() + arc_rect
, point6
.y())
1219 span_angle_arc1
= 90
1223 path
.arcTo(QRectF(point2
, point3
), start_angle_arc1
, span_angle_arc1
)
1225 path
.arcTo(QRectF(point6
, point5
), start_angle_arc2
, span_angle_arc2
)
1229 self
.path_valid
= True
1231 def paint(self
, painter
, _option
, _widget
):
1232 if not self
.path_valid
:
1233 self
.recompute_path()
1234 painter
.setPen(self
.pen
)
1235 painter
.drawPath(self
.path
)
1238 class EdgeColor(object):
1239 """An edge color factory"""
1241 current_color_index
= 0
1243 QtGui
.QColor(Qt
.red
),
1244 QtGui
.QColor(Qt
.cyan
),
1245 QtGui
.QColor(Qt
.magenta
),
1246 QtGui
.QColor(Qt
.green
),
1247 # Orange; Qt.yellow is too low-contrast
1248 qtutils
.rgba(0xFF, 0x66, 0x00),
1252 def update_colors(cls
, theme
):
1253 """Update the colors based on the color theme"""
1254 if theme
.is_dark
or theme
.is_palette_dark
:
1256 QtGui
.QColor(Qt
.red
).lighter(),
1257 QtGui
.QColor(Qt
.cyan
).lighter(),
1258 QtGui
.QColor(Qt
.magenta
).lighter(),
1259 QtGui
.QColor(Qt
.green
).lighter(),
1260 QtGui
.QColor(Qt
.yellow
).lighter(),
1264 QtGui
.QColor(Qt
.blue
),
1265 QtGui
.QColor(Qt
.darkRed
),
1266 QtGui
.QColor(Qt
.darkCyan
),
1267 QtGui
.QColor(Qt
.darkMagenta
),
1268 QtGui
.QColor(Qt
.darkGreen
),
1269 QtGui
.QColor(Qt
.darkYellow
),
1270 QtGui
.QColor(Qt
.darkBlue
),
1275 cls
.current_color_index
+= 1
1276 cls
.current_color_index
%= len(cls
.colors
)
1277 color
= cls
.colors
[cls
.current_color_index
]
1283 return cls
.colors
[cls
.current_color_index
]
1287 cls
.current_color_index
= 0
1290 class Commit(QtWidgets
.QGraphicsItem
):
1291 item_type
= qtutils
.standard_item_type_value(2)
1292 commit_radius
= 12.0
1295 item_shape
= QtGui
.QPainterPath()
1297 commit_radius
/ -2.0, commit_radius
/ -2.0, commit_radius
, commit_radius
1299 item_bbox
= item_shape
.boundingRect()
1301 inner_rect
= QtGui
.QPainterPath()
1303 commit_radius
/ -2.0 + 2.0,
1304 commit_radius
/ -2.0 + 2.0,
1305 commit_radius
- 4.0,
1306 commit_radius
- 4.0,
1308 inner_rect
= inner_rect
.boundingRect()
1310 commit_color
= QtGui
.QColor(Qt
.white
)
1311 outline_color
= commit_color
.darker()
1312 merge_color
= QtGui
.QColor(Qt
.lightGray
)
1314 commit_selected_color
= QtGui
.QColor(Qt
.green
)
1315 selected_outline_color
= commit_selected_color
.darker()
1317 commit_pen
= QtGui
.QPen()
1318 commit_pen
.setWidth(1)
1319 commit_pen
.setColor(outline_color
)
1324 selectable
=QtWidgets
.QGraphicsItem
.ItemIsSelectable
,
1325 cursor
=Qt
.PointingHandCursor
,
1326 xpos
=commit_radius
/ 2.0 + 1.0,
1327 cached_commit_color
=commit_color
,
1328 cached_merge_color
=merge_color
,
1330 QtWidgets
.QGraphicsItem
.__init
__(self
)
1332 self
.commit
= commit
1333 self
.selected
= False
1336 self
.setFlag(selectable
)
1337 self
.setCursor(cursor
)
1338 self
.setToolTip(commit
.oid
[:12] + ': ' + commit
.summary
)
1341 self
.label
= label
= Label(commit
)
1342 label
.setParentItem(self
)
1343 label
.setPos(xpos
+ 1, -self
.commit_radius
/ 2.0)
1347 if len(commit
.parents
) > 1:
1348 self
.brush
= cached_merge_color
1350 self
.brush
= cached_commit_color
1352 self
.pressed
= False
1353 self
.dragged
= False
1356 def itemChange(self
, change
, value
):
1357 if change
== QtWidgets
.QGraphicsItem
.ItemSelectedHasChanged
:
1358 # Cache the pen for use in paint()
1360 self
.brush
= self
.commit_selected_color
1361 color
= self
.selected_outline_color
1363 if len(self
.commit
.parents
) > 1:
1364 self
.brush
= self
.merge_color
1366 self
.brush
= self
.commit_color
1367 color
= self
.outline_color
1368 commit_pen
= QtGui
.QPen()
1369 commit_pen
.setWidth(1)
1370 commit_pen
.setColor(color
)
1371 self
.commit_pen
= commit_pen
1373 return QtWidgets
.QGraphicsItem
.itemChange(self
, change
, value
)
1376 return self
.item_type
1378 def boundingRect(self
):
1379 return self
.item_bbox
1382 return self
.item_shape
1384 def paint(self
, painter
, option
, _widget
):
1386 # Do not draw outside the exposed rect
1387 painter
.setClipRect(option
.exposedRect
)
1390 painter
.setPen(self
.commit_pen
)
1391 painter
.setBrush(self
.brush
)
1392 painter
.drawEllipse(self
.inner_rect
)
1394 def mousePressEvent(self
, event
):
1395 QtWidgets
.QGraphicsItem
.mousePressEvent(self
, event
)
1397 self
.selected
= self
.isSelected()
1399 def mouseMoveEvent(self
, event
):
1402 QtWidgets
.QGraphicsItem
.mouseMoveEvent(self
, event
)
1404 def mouseReleaseEvent(self
, event
):
1405 QtWidgets
.QGraphicsItem
.mouseReleaseEvent(self
, event
)
1406 if not self
.dragged
and self
.selected
and event
.button() == Qt
.LeftButton
:
1408 self
.pressed
= False
1409 self
.dragged
= False
1412 class Label(QtWidgets
.QGraphicsItem
):
1414 item_type
= qtutils
.graphics_item_type_value(3)
1416 head_color
= QtGui
.QColor(Qt
.green
)
1417 other_color
= QtGui
.QColor(Qt
.white
)
1418 remote_color
= QtGui
.QColor(Qt
.yellow
)
1420 head_pen
= QtGui
.QPen()
1421 head_pen
.setColor(head_color
.darker().darker())
1422 head_pen
.setWidth(1)
1424 text_pen
= QtGui
.QPen()
1425 text_pen
.setColor(QtGui
.QColor(Qt
.black
))
1426 text_pen
.setWidth(1)
1429 head_color
.setAlpha(alpha
)
1430 other_color
.setAlpha(alpha
)
1431 remote_color
.setAlpha(alpha
)
1437 def __init__(self
, commit
):
1438 QtWidgets
.QGraphicsItem
.__init
__(self
)
1440 self
.commit
= commit
1443 return self
.item_type
1445 def boundingRect(self
, cache
=Cache
):
1446 QPainterPath
= QtGui
.QPainterPath
1447 QRectF
= QtCore
.QRectF
1452 spacing
= self
.item_spacing
1453 border
= self
.border
+ self
.text_offset
# text offset=1 in paint()
1455 font
= cache
.label_font()
1456 item_shape
= QPainterPath()
1458 base_rect
= QRectF(0, 0, width
, height
)
1459 base_rect
= base_rect
.adjusted(-border
, -border
, border
, border
)
1460 item_shape
.addRect(base_rect
)
1462 for tag
in self
.commit
.tags
:
1463 text_shape
= QPainterPath()
1464 text_shape
.addText(current_width
, 0, font
, tag
)
1465 text_rect
= text_shape
.boundingRect()
1466 box_rect
= text_rect
.adjusted(-border
, -border
, border
, border
)
1467 item_shape
.addRect(box_rect
)
1468 current_width
= item_shape
.boundingRect().width() + spacing
1470 return item_shape
.boundingRect()
1472 def paint(self
, painter
, _option
, _widget
, cache
=Cache
):
1473 # Draw tags and branches
1474 font
= cache
.label_font()
1475 painter
.setFont(font
)
1478 border
= self
.border
1479 offset
= self
.text_offset
1480 spacing
= self
.item_spacing
1481 QRectF
= QtCore
.QRectF
1484 remotes_prefix
= 'remotes/'
1485 tags_prefix
= 'tags/'
1486 heads_prefix
= 'heads/'
1487 remotes_len
= len(remotes_prefix
)
1488 tags_len
= len(tags_prefix
)
1489 heads_len
= len(heads_prefix
)
1491 for tag
in self
.commit
.tags
:
1493 painter
.setPen(self
.text_pen
)
1494 painter
.setBrush(self
.remote_color
)
1495 elif tag
.startswith(remotes_prefix
):
1496 tag
= tag
[remotes_len
:]
1497 painter
.setPen(self
.text_pen
)
1498 painter
.setBrush(self
.other_color
)
1499 elif tag
.startswith(tags_prefix
):
1500 tag
= tag
[tags_len
:]
1501 painter
.setPen(self
.text_pen
)
1502 painter
.setBrush(self
.remote_color
)
1503 elif tag
.startswith(heads_prefix
):
1504 tag
= tag
[heads_len
:]
1505 painter
.setPen(self
.head_pen
)
1506 painter
.setBrush(self
.head_color
)
1508 painter
.setPen(self
.text_pen
)
1509 painter
.setBrush(self
.other_color
)
1511 text_rect
= painter
.boundingRect(
1512 QRectF(current_width
, 0, 0, 0), Qt
.TextSingleLine
, tag
1514 box_rect
= text_rect
.adjusted(-offset
, -offset
, offset
, offset
)
1516 painter
.drawRoundedRect(box_rect
, border
, border
)
1517 painter
.drawText(text_rect
, Qt
.TextSingleLine
, tag
)
1518 current_width
+= text_rect
.width() + spacing
1521 # pylint: disable=too-many-ancestors
1522 class GraphView(QtWidgets
.QGraphicsView
, ViewerMixin
):
1524 commits_selected
= Signal(object)
1525 diff_commits
= Signal(object, object)
1527 x_adjust
= int(Commit
.commit_radius
* 4 / 3)
1528 y_adjust
= int(Commit
.commit_radius
* 4 / 3)
1533 def __init__(self
, context
, parent
):
1534 QtWidgets
.QGraphicsView
.__init
__(self
, parent
)
1535 ViewerMixin
.__init
__(self
)
1536 EdgeColor
.update_colors(context
.app
.theme
)
1538 theme
= context
.app
.theme
1539 highlight
= theme
.selection_color()
1540 Commit
.commit_selected_color
= highlight
1541 Commit
.selected_outline_color
= highlight
.darker()
1543 self
.context
= context
1545 self
.menu_actions
= None
1548 self
.mouse_start
= [0, 0]
1549 self
.saved_matrix
= self
.transform()
1553 self
.tagged_cells
= set()
1557 self
.x_offsets
= collections
.defaultdict(lambda: self
.x_min
)
1559 self
.is_panning
= False
1560 self
.pressed
= False
1561 self
.selecting
= False
1562 self
.last_mouse
= [0, 0]
1564 self
.setDragMode(self
.RubberBandDrag
)
1566 scene
= QtWidgets
.QGraphicsScene(self
)
1567 scene
.setItemIndexMethod(QtWidgets
.QGraphicsScene
.BspTreeIndex
)
1568 self
.setScene(scene
)
1570 # pylint: disable=no-member
1571 scene
.selectionChanged
.connect(self
.selection_changed
)
1573 self
.setRenderHint(QtGui
.QPainter
.Antialiasing
)
1574 self
.setViewportUpdateMode(self
.SmartViewportUpdate
)
1575 self
.setCacheMode(QtWidgets
.QGraphicsView
.CacheBackground
)
1576 self
.setTransformationAnchor(QtWidgets
.QGraphicsView
.AnchorUnderMouse
)
1577 self
.setResizeAnchor(QtWidgets
.QGraphicsView
.NoAnchor
)
1579 background_color
= qtutils
.css_color(context
.app
.theme
.background_color_rgb())
1580 self
.setBackgroundBrush(background_color
)
1587 hotkeys
.ZOOM_IN_SECONDARY
,
1590 qtutils
.add_action(self
, N_('Zoom Out'), self
.zoom_out
, hotkeys
.ZOOM_OUT
)
1592 qtutils
.add_action(self
, N_('Zoom to Fit'), self
.zoom_to_fit
, hotkeys
.FIT
)
1595 self
, N_('Select Parent'), self
._select
_parent
, hotkeys
.MOVE_DOWN_TERTIARY
1600 N_('Select Oldest Parent'),
1601 self
._select
_oldest
_parent
,
1606 self
, N_('Select Child'), self
._select
_child
, hotkeys
.MOVE_UP_TERTIARY
1610 self
, N_('Select Newest Child'), self
._select
_newest
_child
, hotkeys
.MOVE_UP
1615 self
.scene().clear()
1617 self
.x_offsets
.clear()
1621 # ViewerMixin interface
1622 def selected_items(self
):
1623 """Return the currently selected items"""
1624 return self
.scene().selectedItems()
1627 self
.scale_view(1.5)
1630 self
.scale_view(1.0 / 1.5)
1632 def selection_changed(self
):
1633 # Broadcast selection to other widgets
1634 selected_items
= self
.scene().selectedItems()
1635 commits
= sort_by_generation([item
.commit
for item
in selected_items
])
1636 self
.set_selecting(True)
1637 self
.commits_selected
.emit(commits
)
1638 self
.set_selecting(False)
1640 def select_commits(self
, commits
):
1643 with qtutils
.BlockSignals(self
.scene()):
1644 self
.select([commit
.oid
for commit
in commits
])
1646 def select(self
, oids
):
1647 """Select the item for the oids"""
1648 self
.scene().clearSelection()
1651 item
= self
.items
[oid
]
1654 item
.setSelected(True)
1655 item_rect
= item
.sceneTransform().mapRect(item
.boundingRect())
1656 self
.ensureVisible(item_rect
)
1658 def _get_item_by_generation(self
, commits
, criteria_func
):
1659 """Return the item for the commit matching criteria"""
1663 for commit
in commits
:
1664 if generation
is None or criteria_func(generation
, commit
.generation
):
1666 generation
= commit
.generation
1668 return self
.items
[oid
]
1672 def _oldest_item(self
, commits
):
1673 """Return the item for the commit with the oldest generation number"""
1674 return self
._get
_item
_by
_generation
(commits
, lambda a
, b
: a
> b
)
1676 def _newest_item(self
, commits
):
1677 """Return the item for the commit with the newest generation number"""
1678 return self
._get
_item
_by
_generation
(commits
, lambda a
, b
: a
< b
)
1680 def create_patch(self
):
1681 items
= self
.selected_items()
1684 context
= self
.context
1685 selected_commits
= sort_by_generation([n
.commit
for n
in items
])
1686 oids
= [c
.oid
for c
in selected_commits
]
1687 all_oids
= [c
.oid
for c
in sort_by_generation(self
.commits
)]
1688 cmds
.do(cmds
.FormatPatch
, context
, oids
, all_oids
)
1690 def _select_parent(self
):
1691 """Select the parent with the newest generation number"""
1692 selected_item
= self
.selected_item()
1693 if selected_item
is None:
1695 parent_item
= self
._newest
_item
(selected_item
.commit
.parents
)
1696 if parent_item
is None:
1698 selected_item
.setSelected(False)
1699 parent_item
.setSelected(True)
1700 self
.ensureVisible(parent_item
.mapRectToScene(parent_item
.boundingRect()))
1702 def _select_oldest_parent(self
):
1703 """Select the parent with the oldest generation number"""
1704 selected_item
= self
.selected_item()
1705 if selected_item
is None:
1707 parent_item
= self
._oldest
_item
(selected_item
.commit
.parents
)
1708 if parent_item
is None:
1710 selected_item
.setSelected(False)
1711 parent_item
.setSelected(True)
1712 scene_rect
= parent_item
.mapRectToScene(parent_item
.boundingRect())
1713 self
.ensureVisible(scene_rect
)
1715 def _select_child(self
):
1716 """Select the child with the oldest generation number"""
1717 selected_item
= self
.selected_item()
1718 if selected_item
is None:
1720 child_item
= self
._oldest
_item
(selected_item
.commit
.children
)
1721 if child_item
is None:
1723 selected_item
.setSelected(False)
1724 child_item
.setSelected(True)
1725 scene_rect
= child_item
.mapRectToScene(child_item
.boundingRect())
1726 self
.ensureVisible(scene_rect
)
1728 def _select_newest_child(self
):
1729 """Select the Nth child with the newest generation number (N > 1)"""
1730 selected_item
= self
.selected_item()
1731 if selected_item
is None:
1733 if len(selected_item
.commit
.children
) > 1:
1734 children
= selected_item
.commit
.children
[1:]
1736 children
= selected_item
.commit
.children
1737 child_item
= self
._newest
_item
(children
)
1738 if child_item
is None:
1740 selected_item
.setSelected(False)
1741 child_item
.setSelected(True)
1742 scene_rect
= child_item
.mapRectToScene(child_item
.boundingRect())
1743 self
.ensureVisible(scene_rect
)
1745 def set_initial_view(self
):
1747 selected
= self
.selected_items()
1749 items
.extend(selected
)
1751 if not selected
and self
.commits
:
1752 commit
= self
.commits
[-1]
1753 items
.append(self
.items
[commit
.oid
])
1755 self
.setSceneRect(self
.scene().itemsBoundingRect())
1756 self
.fit_view_to_items(items
)
1758 def zoom_to_fit(self
):
1759 """Fit selected items into the viewport"""
1760 items
= self
.selected_items()
1761 self
.fit_view_to_items(items
)
1763 def fit_view_to_items(self
, items
):
1765 rect
= self
.scene().itemsBoundingRect()
1767 x_min
= y_min
= maxsize
1768 x_max
= y_max
= -maxsize
1774 x_min
= min(x_min
, x_val
)
1775 x_max
= max(x_max
, x_val
)
1776 y_min
= min(y_min
, y_val
)
1777 y_max
= max(y_max
, y_val
)
1779 rect
= QtCore
.QRectF(x_min
, y_min
, abs(x_max
- x_min
), abs(y_max
- y_min
))
1781 x_adjust
= abs(GraphView
.x_adjust
)
1782 y_adjust
= abs(GraphView
.y_adjust
)
1784 count
= max(2.0, 10.0 - len(items
) / 2.0)
1785 y_offset
= int(y_adjust
* count
)
1786 x_offset
= int(x_adjust
* count
)
1787 rect
.setX(rect
.x() - x_offset
// 2)
1788 rect
.setY(rect
.y() - y_adjust
// 2)
1789 rect
.setHeight(rect
.height() + y_offset
)
1790 rect
.setWidth(rect
.width() + x_offset
)
1792 self
.fitInView(rect
, Qt
.KeepAspectRatio
)
1793 self
.scene().invalidate()
1795 def handle_event(self
, event_handler
, event
, update
=True):
1796 event_handler(self
, event
)
1800 def set_selecting(self
, selecting
):
1801 self
.selecting
= selecting
1803 def pan(self
, event
):
1805 x_offset
= pos
.x() - self
.mouse_start
[0]
1806 y_offset
= pos
.y() - self
.mouse_start
[1]
1808 if x_offset
== 0 and y_offset
== 0:
1811 rect
= QtCore
.QRect(0, 0, abs(x_offset
), abs(y_offset
))
1812 delta
= self
.mapToScene(rect
).boundingRect()
1814 x_translate
= delta
.width()
1816 x_translate
= -x_translate
1818 y_translate
= delta
.height()
1820 y_translate
= -y_translate
1822 matrix
= self
.transform()
1824 matrix
*= self
.saved_matrix
1825 matrix
.translate(x_translate
, y_translate
)
1827 self
.setTransformationAnchor(QtWidgets
.QGraphicsView
.NoAnchor
)
1828 self
.setTransform(matrix
)
1830 def wheel_zoom(self
, event
):
1831 """Handle mouse wheel zooming."""
1832 delta
= qtcompat
.wheel_delta(event
)
1833 zoom
= math
.pow(2.0, delta
/ 512.0)
1837 .mapRect(QtCore
.QRectF(0.0, 0.0, 1.0, 1.0))
1840 if factor
< 0.014 or factor
> 42.0:
1842 self
.setTransformationAnchor(QtWidgets
.QGraphicsView
.AnchorUnderMouse
)
1844 self
.scale(zoom
, zoom
)
1846 def wheel_pan(self
, event
):
1847 """Handle mouse wheel panning."""
1848 unit
= QtCore
.QRectF(0.0, 0.0, 1.0, 1.0)
1849 factor
= 1.0 / self
.transform().mapRect(unit
).width()
1850 tx
, ty
= qtcompat
.wheel_translation(event
)
1852 matrix
= self
.transform().translate(tx
* factor
, ty
* factor
)
1853 self
.setTransformationAnchor(QtWidgets
.QGraphicsView
.NoAnchor
)
1854 self
.setTransform(matrix
)
1856 def scale_view(self
, scale
):
1859 .scale(scale
, scale
)
1860 .mapRect(QtCore
.QRectF(0, 0, 1, 1))
1863 if factor
< 0.07 or factor
> 100.0:
1867 adjust_scrollbars
= True
1868 scrollbar
= self
.verticalScrollBar()
1870 value
= get(scrollbar
)
1871 min_
= scrollbar
.minimum()
1872 max_
= scrollbar
.maximum()
1873 range_
= max_
- min_
1874 distance
= value
- min_
1875 nonzero_range
= range_
> 0.1
1877 scrolloffset
= distance
/ range_
1879 adjust_scrollbars
= False
1881 self
.setTransformationAnchor(QtWidgets
.QGraphicsView
.NoAnchor
)
1882 self
.scale(scale
, scale
)
1884 scrollbar
= self
.verticalScrollBar()
1885 if scrollbar
and adjust_scrollbars
:
1886 min_
= scrollbar
.minimum()
1887 max_
= scrollbar
.maximum()
1888 range_
= max_
- min_
1889 value
= min_
+ int(float(range_
) * scrolloffset
)
1890 scrollbar
.setValue(value
)
1892 def add_commits(self
, commits
):
1893 """Traverse commits and add them to the view."""
1894 self
.commits
.extend(commits
)
1895 scene
= self
.scene()
1896 for commit
in commits
:
1897 item
= Commit(commit
)
1898 self
.items
[commit
.oid
] = item
1899 for ref
in commit
.tags
:
1900 self
.items
[ref
] = item
1903 self
.layout_commits()
1906 def link(self
, commits
):
1907 """Create edges linking commits with their parents"""
1908 scene
= self
.scene()
1909 for commit
in commits
:
1911 commit_item
= self
.items
[commit
.oid
]
1913 continue # The history is truncated.
1914 for parent
in reversed(commit
.parents
):
1916 parent_item
= self
.items
[parent
.oid
]
1918 continue # The history is truncated.
1920 edge
= parent_item
.edges
[commit
.oid
]
1922 edge
= Edge(parent_item
, commit_item
)
1925 parent_item
.edges
[commit
.oid
] = edge
1926 commit_item
.edges
[parent
.oid
] = edge
1929 def layout_commits(self
):
1930 positions
= self
.position_nodes()
1932 # Each edge is accounted in two commits. Hence, accumulate invalid
1933 # edges to prevent double edge invalidation.
1934 invalid_edges
= set()
1936 for oid
, (x_val
, y_val
) in positions
.items():
1937 item
= self
.items
[oid
]
1940 if pos
!= (x_val
, y_val
):
1941 item
.setPos(x_val
, y_val
)
1943 for edge
in item
.edges
.values():
1944 invalid_edges
.add(edge
)
1946 for edge
in invalid_edges
:
1947 edge
.commits_were_invalidated()
1949 # Commit node layout technique
1951 # Nodes are aligned by a mesh. Columns and rows are distributed using
1952 # algorithms described below.
1954 # Row assignment algorithm
1956 # The algorithm aims consequent.
1957 # 1. A commit should be above all its parents.
1958 # 2. No commit should be at right side of a commit with a tag in same row.
1959 # This prevents overlapping of tag labels with commits and other labels.
1960 # 3. Commit density should be maximized.
1962 # The algorithm requires that all parents of a commit were assigned column.
1963 # Nodes must be traversed in generation ascend order. This guarantees that all
1964 # parents of a commit were assigned row. So, the algorithm may operate in
1965 # course of column assignment algorithm.
1967 # Row assignment uses frontier. A frontier is a dictionary that contains
1968 # minimum available row index for each column. It propagates during the
1969 # algorithm. Set of cells with tags is also maintained to meet second aim.
1971 # Initialization is performed by reset_rows method. Each new column should
1972 # be declared using declare_column method. Getting row for a cell is
1973 # implemented in alloc_cell method. Frontier must be propagated for any child
1974 # of fork commit which occupies different column. This meets first aim.
1976 # Column assignment algorithm
1978 # The algorithm traverses nodes in generation ascend order. This guarantees
1979 # that a node will be visited after all its parents.
1981 # The set of occupied columns are maintained during work. Initially it is
1982 # empty and no node occupied a column. Empty columns are allocated on demand.
1983 # Free index for column being allocated is searched in following way.
1984 # 1. Start from desired column and look towards graph center (0 column).
1985 # 2. Start from center and look in both directions simultaneously.
1986 # Desired column is defaulted to 0. Fork node should set desired column for
1987 # children equal to its one. This prevents branch from jumping too far from
1990 # Initialization is performed by reset_columns method. Column allocation is
1991 # implemented in alloc_column method. Initialization and main loop are in
1992 # recompute_grid method. The method also embeds row assignment algorithm by
1995 # Actions for each node are follow.
1996 # 1. If the node was not assigned a column then it is assigned empty one.
1998 # 3. Allocate columns for children.
1999 # If a child have a column assigned then it should no be overridden. One of
2000 # children is assigned same column as the node. If the node is a fork then the
2001 # child is chosen in generation descent order. This is a heuristic and it only
2002 # affects resulting appearance of the graph. Other children are assigned empty
2003 # columns in same order. It is the heuristic too.
2004 # 4. If no child occupies column of the node then leave it.
2005 # It is possible in consequent situations.
2006 # 4.1 The node is a leaf.
2007 # 4.2 The node is a fork and all its children are already assigned side
2008 # column. It is possible if all the children are merges.
2009 # 4.3 Single node child is a merge that is already assigned a column.
2010 # 5. Propagate frontier with respect to this node.
2011 # Each frontier entry corresponding to column occupied by any node's child
2012 # must be gather than node row index. This meets first aim of the row
2013 # assignment algorithm.
2014 # Note that frontier of child that occupies same row was propagated during
2015 # step 2. Hence, it must be propagated for children on side columns.
2017 def reset_columns(self
):
2018 # Some children of displayed commits might not be accounted in
2019 # 'commits' list. It is common case during loading of big graph.
2020 # But, they are assigned a column that must be reseted. Hence, use
2021 # depth-first traversal to reset all columns assigned.
2022 for node
in self
.commits
:
2023 if node
.column
is None:
2029 for child
in node
.children
:
2030 if child
.column
is not None:
2037 def reset_rows(self
):
2039 self
.tagged_cells
= set()
2041 def declare_column(self
, column
):
2043 # Align new column frontier by frontier of nearest column. If all
2044 # columns were left then select maximum frontier value.
2045 if not self
.columns
:
2046 self
.frontier
[column
] = max(list(self
.frontier
.values()))
2048 # This is heuristic that mostly affects roots. Note that the
2049 # frontier values for fork children will be overridden in course of
2050 # propagate_frontier.
2051 for offset
in itertools
.count(1):
2052 for value
in (column
+ offset
, column
- offset
):
2053 if value
not in self
.columns
:
2054 # Column is not occupied.
2057 frontier
= self
.frontier
[value
]
2059 # Column 'c' was never allocated.
2063 # The frontier of the column may be higher because of
2064 # tag overlapping prevention performed for previous head.
2066 if self
.frontier
[column
] >= frontier
:
2071 self
.frontier
[column
] = frontier
2077 # First commit must be assigned 0 row.
2078 self
.frontier
[column
] = 0
2080 def alloc_column(self
, column
=0):
2081 columns
= self
.columns
2082 # First, look for free column by moving from desired column to graph
2083 # center (column 0).
2084 for c
in range(column
, 0, -1 if column
> 0 else 1):
2085 if c
not in columns
:
2086 if c
> self
.max_column
:
2088 elif c
< self
.min_column
:
2092 # If no free column was found between graph center and desired
2093 # column then look for free one by moving from center along both
2094 # directions simultaneously.
2095 for c
in itertools
.count(0):
2096 if c
not in columns
:
2097 if c
> self
.max_column
:
2101 if c
not in columns
:
2102 if c
< self
.min_column
:
2105 self
.declare_column(c
)
2109 def alloc_cell(self
, column
, tags
):
2110 # Get empty cell from frontier.
2111 cell_row
= self
.frontier
[column
]
2114 # Prevent overlapping of tag with cells already allocated a row.
2116 can_overlap
= list(range(column
+ 1, self
.max_column
+ 1))
2118 can_overlap
= list(range(column
- 1, self
.min_column
- 1, -1))
2119 for value
in can_overlap
:
2120 frontier
= self
.frontier
[value
]
2121 if frontier
> cell_row
:
2124 # Avoid overlapping with tags of commits at cell_row.
2126 can_overlap
= list(range(self
.min_column
, column
))
2128 can_overlap
= list(range(self
.max_column
, column
, -1))
2129 for cell_row
in itertools
.count(cell_row
):
2130 for value
in can_overlap
:
2131 if (value
, cell_row
) in self
.tagged_cells
:
2132 # Overlapping. Try next row.
2135 # No overlapping was found.
2137 # Note that all checks should be made for new cell_row value.
2140 self
.tagged_cells
.add((column
, cell_row
))
2142 # Propagate frontier.
2143 self
.frontier
[column
] = cell_row
+ 1
2146 def propagate_frontier(self
, column
, value
):
2147 current
= self
.frontier
[column
]
2149 self
.frontier
[column
] = value
2151 def leave_column(self
, column
):
2152 count
= self
.columns
[column
]
2154 del self
.columns
[column
]
2156 self
.columns
[column
] = count
- 1
2158 def recompute_grid(self
):
2159 self
.reset_columns()
2162 for node
in sort_by_generation(list(self
.commits
)):
2163 if node
.column
is None:
2164 # Node is either root or its parent is not in items. The last
2165 # happens when tree loading is in progress. Allocate new
2166 # columns for such nodes.
2167 node
.column
= self
.alloc_column()
2169 node
.row
= self
.alloc_cell(node
.column
, node
.tags
)
2171 # Allocate columns for children which are still without one. Also
2172 # propagate frontier for children.
2174 sorted_children
= sorted(
2175 node
.children
, key
=lambda c
: c
.generation
, reverse
=True
2177 citer
= iter(sorted_children
)
2179 if child
.column
is None:
2180 # Top most child occupies column of parent.
2181 child
.column
= node
.column
2182 # Note that frontier is propagated in course of
2185 self
.propagate_frontier(child
.column
, node
.row
+ 1)
2187 # No child occupies same column.
2188 self
.leave_column(node
.column
)
2189 # Note that the loop below will pass no iteration.
2191 # Rest children are allocated new column.
2193 if child
.column
is None:
2194 child
.column
= self
.alloc_column(node
.column
)
2195 self
.propagate_frontier(child
.column
, node
.row
+ 1)
2197 child
= node
.children
[0]
2198 if child
.column
is None:
2199 child
.column
= node
.column
2200 # Note that frontier is propagated in course of alloc_cell.
2201 elif child
.column
!= node
.column
:
2202 # Child node have other parents and occupies column of one
2204 self
.leave_column(node
.column
)
2205 # But frontier must be propagated with respect to this
2207 self
.propagate_frontier(child
.column
, node
.row
+ 1)
2209 # This is a leaf node.
2210 self
.leave_column(node
.column
)
2212 def position_nodes(self
):
2213 self
.recompute_grid()
2215 x_start
= self
.x_start
2222 for node
in self
.commits
:
2223 x_val
= x_start
+ node
.column
* x_off
2224 y_val
= y_off
+ node
.row
* y_off
2226 positions
[node
.oid
] = (x_val
, y_val
)
2227 x_min
= min(x_min
, x_val
)
2234 def contextMenuEvent(self
, event
):
2235 self
.context_menu_event(event
)
2237 def mousePressEvent(self
, event
):
2238 if event
.button() == Qt
.MidButton
:
2240 self
.mouse_start
= [pos
.x(), pos
.y()]
2241 self
.saved_matrix
= self
.transform()
2242 self
.is_panning
= True
2244 if event
.button() == Qt
.RightButton
:
2247 if event
.button() == Qt
.LeftButton
:
2249 self
.handle_event(QtWidgets
.QGraphicsView
.mousePressEvent
, event
)
2251 def mouseMoveEvent(self
, event
):
2255 pos
= self
.mapToScene(event
.pos())
2256 self
.last_mouse
[0] = pos
.x()
2257 self
.last_mouse
[1] = pos
.y()
2258 self
.handle_event(QtWidgets
.QGraphicsView
.mouseMoveEvent
, event
, update
=False)
2260 def mouseReleaseEvent(self
, event
):
2261 self
.pressed
= False
2262 if event
.button() == Qt
.MidButton
:
2263 self
.is_panning
= False
2265 self
.handle_event(QtWidgets
.QGraphicsView
.mouseReleaseEvent
, event
)
2266 self
.viewport().repaint()
2268 def wheelEvent(self
, event
):
2269 """Handle Qt mouse wheel events."""
2270 if event
.modifiers() & Qt
.ControlModifier
:
2271 self
.wheel_zoom(event
)
2273 self
.wheel_pan(event
)
2275 def fitInView(self
, rect
, flags
=Qt
.IgnoreAspectRatio
):
2276 """Override fitInView to remove unwanted margins
2278 https://bugreports.qt.io/browse/QTBUG-42331 - based on QT sources
2281 if self
.scene() is None or rect
.isNull():
2283 unity
= self
.transform().mapRect(QtCore
.QRectF(0, 0, 1, 1))
2284 self
.scale(1.0 / unity
.width(), 1.0 / unity
.height())
2285 view_rect
= self
.viewport().rect()
2286 scene_rect
= self
.transform().mapRect(rect
)
2287 xratio
= view_rect
.width() / scene_rect
.width()
2288 yratio
= view_rect
.height() / scene_rect
.height()
2289 if flags
== Qt
.KeepAspectRatio
:
2290 xratio
= yratio
= min(xratio
, yratio
)
2291 elif flags
== Qt
.KeepAspectRatioByExpanding
:
2292 xratio
= yratio
= max(xratio
, yratio
)
2293 self
.scale(xratio
, yratio
)
2294 self
.centerOn(rect
.center())
2297 def sort_by_generation(commits
):
2298 """Sort commits by their generation. Ensures consistent diffs and patch exports"""
2299 if len(commits
) <= 1:
2301 commits
.sort(key
=lambda x
: x
.generation
)
2307 # oid -- Git objects IDs (i.e. SHA-1 IDs)
2308 # ref -- Git references that resolve to a commit-ish (HEAD, branches, tags)