4 from functools
import partial
6 from qtpy
.QtCore
import Qt
7 from qtpy
.QtCore
import Signal
8 from qtpy
import QtCore
10 from qtpy
import QtWidgets
12 from ..compat
import maxsize
14 from ..models
import dag
15 from ..models
import main
16 from ..qtutils
import get
19 from .. import difftool
20 from .. import gitcmds
21 from .. import guicmds
22 from .. import hotkeys
24 from .. import qtcompat
25 from .. import qtutils
29 from . import completion
30 from . import createbranch
31 from . import createtag
34 from . import filelist
35 from . import standard
38 def git_dag(context
, args
=None, existing_view
=None, show
=True):
39 """Return a pre-populated git DAG widget."""
41 branch
= model
.currentbranch
42 # disambiguate between branch names and filenames by using '--'
43 branch_doubledash
= (branch
+ ' --') if branch
else ''
44 params
= dag
.DAG(branch_doubledash
, 1000)
45 params
.set_arguments(args
)
47 if existing_view
is None:
48 view
= GitDAG(context
, params
)
51 view
.set_params(params
)
59 class FocusRedirectProxy
:
60 """Redirect actions from the main widget to child widgets"""
62 def __init__(self
, *widgets
):
63 """Provide proxied widgets; the default widget must be first"""
64 self
.widgets
= widgets
65 self
.default
= widgets
[0]
67 def __getattr__(self
, name
):
68 return lambda *args
, **kwargs
: self
._forward
_action
(name
, *args
, **kwargs
)
70 def _forward_action(self
, name
, *args
, **kwargs
):
71 """Forward the captured action to the focused or default widget"""
72 widget
= QtWidgets
.QApplication
.focusWidget()
73 if widget
in self
.widgets
and hasattr(widget
, name
):
74 func
= getattr(widget
, name
)
76 func
= getattr(self
.default
, name
)
78 return func(*args
, **kwargs
)
82 """Implementations must provide selected_items()"""
85 self
.context
= None # provided by implementation
88 self
.menu_actions
= None # provided by implementation
90 def selected_item(self
):
91 """Return the currently selected item"""
92 selected_items
= self
.selected_items()
93 if not selected_items
:
95 return selected_items
[0]
97 def selected_oid(self
):
98 """Return the currently selected commit object ID"""
99 item
= self
.selected_item()
103 result
= item
.commit
.oid
106 def selected_oids(self
):
107 """Return the currently selected comit object IDs"""
108 return [i
.commit
for i
in self
.selected_items()]
110 def clicked_oid(self
):
111 """Return the clicked or selected commit object ID"""
113 return self
.clicked
.oid
114 return self
.selected_oid()
116 def with_oid(self
, func
):
117 """Run an operation with a commit object ID"""
118 oid
= self
.clicked_oid()
125 def with_selected_oid(self
, func
):
126 """Run an operation with a commit object ID"""
127 oid
= self
.selected_oid()
134 def diff_selected_this(self
):
135 """Diff the selected commit against the clicked commit"""
136 clicked_oid
= self
.clicked
.oid
137 selected_oid
= self
.selected
.oid
138 self
.diff_commits
.emit(selected_oid
, clicked_oid
)
140 def diff_this_selected(self
):
141 """Diff the clicked commit against the selected commit"""
142 clicked_oid
= self
.clicked
.oid
143 selected_oid
= self
.selected
.oid
144 self
.diff_commits
.emit(clicked_oid
, selected_oid
)
146 def cherry_pick(self
):
147 """Cherry-pick a commit using git cherry-pick"""
148 context
= self
.context
149 self
.with_oid(lambda oid
: cmds
.do(cmds
.CherryPick
, context
, [oid
]))
152 """Revert a commit using git revert"""
153 context
= self
.context
154 self
.with_oid(lambda oid
: cmds
.do(cmds
.Revert
, context
, oid
))
156 def copy_to_clipboard(self
):
157 """Copy the current commit object ID to the clipboard"""
158 self
.with_oid(qtutils
.set_clipboard
)
160 def checkout_branch(self
):
161 """Checkout the clicked/selected branch"""
163 clicked
= self
.clicked
164 selected
= self
.selected_item()
166 branches
.extend(clicked
.branches
)
168 branches
.extend(selected
.commit
.branches
)
171 guicmds
.checkout_branch(self
.context
, default
=branches
[0])
173 def create_branch(self
):
174 """Create a branch at the selected commit"""
175 context
= self
.context
176 create_new_branch
= partial(createbranch
.create_new_branch
, context
)
177 self
.with_oid(lambda oid
: create_new_branch(revision
=oid
))
179 def create_tag(self
):
180 """Create a tag at the selected commit"""
181 context
= self
.context
182 self
.with_oid(lambda oid
: createtag
.create_tag(context
, ref
=oid
))
184 def create_tarball(self
):
185 """Create a tarball from the selected commit"""
186 context
= self
.context
187 self
.with_oid(lambda oid
: archive
.show_save_dialog(context
, oid
, parent
=self
))
190 """Show the diff for the selected commit"""
191 context
= self
.context
193 lambda oid
: difftool
.diff_expression(
194 context
, self
, oid
+ '^!', hide_expr
=False, focus_tree
=True
198 def show_dir_diff(self
):
199 """Show a full directory diff for the selected commit"""
200 context
= self
.context
202 lambda oid
: difftool
.difftool_launch(
203 context
, left
=oid
, left_take_magic
=True, dir_diff
=True
207 def rebase_to_commit(self
):
208 """Rebase the current branch to the selected commit"""
209 context
= self
.context
210 self
.with_oid(lambda oid
: cmds
.do(cmds
.Rebase
, context
, upstream
=oid
))
212 def reset_mixed(self
):
213 """Reset the repository using git reset --mixed"""
214 context
= self
.context
215 self
.with_oid(lambda oid
: cmds
.do(cmds
.ResetMixed
, context
, ref
=oid
))
217 def reset_keep(self
):
218 """Reset the repository using git reset --keep"""
219 context
= self
.context
220 self
.with_oid(lambda oid
: cmds
.do(cmds
.ResetKeep
, context
, ref
=oid
))
222 def reset_merge(self
):
223 """Reset the repository using git reset --merge"""
224 context
= self
.context
225 self
.with_oid(lambda oid
: cmds
.do(cmds
.ResetMerge
, context
, ref
=oid
))
227 def reset_soft(self
):
228 """Reset the repository using git reset --soft"""
229 context
= self
.context
230 self
.with_oid(lambda oid
: cmds
.do(cmds
.ResetSoft
, context
, ref
=oid
))
232 def reset_hard(self
):
233 """Reset the repository using git reset --hard"""
234 context
= self
.context
235 self
.with_oid(lambda oid
: cmds
.do(cmds
.ResetHard
, context
, ref
=oid
))
237 def restore_worktree(self
):
238 """Reset the worktree contents from the selected commit"""
239 context
= self
.context
240 self
.with_oid(lambda oid
: cmds
.do(cmds
.RestoreWorktree
, context
, ref
=oid
))
242 def checkout_detached(self
):
243 """Checkout a commit using an anonymous detached HEAD"""
244 context
= self
.context
245 self
.with_oid(lambda oid
: cmds
.do(cmds
.Checkout
, context
, [oid
]))
247 def save_blob_dialog(self
):
248 """Save a file blob from the selected commit"""
249 context
= self
.context
250 self
.with_oid(lambda oid
: browse
.BrowseBranch
.browse(context
, oid
))
252 def update_menu_actions(self
, event
):
253 """Update menu actions to reflect the selection state"""
254 selected_items
= self
.selected_items()
255 selected_item
= self
.selected_item()
256 item
= self
.itemAt(event
.pos())
258 self
.clicked
= commit
= None
260 self
.clicked
= commit
= item
.commit
262 has_single_selection
= len(selected_items
) == 1
263 has_single_selection_or_clicked
= bool(has_single_selection
or commit
)
264 has_selection
= bool(selected_items
)
267 and has_single_selection
269 and commit
is not selected_items
[0].commit
274 and bool(selected_item
.commit
.branches
)
275 ) or (self
.clicked
and bool(self
.clicked
.branches
))
278 self
.selected
= selected_items
[0].commit
282 self
.menu_actions
['diff_this_selected'].setEnabled(can_diff
)
283 self
.menu_actions
['diff_selected_this'].setEnabled(can_diff
)
284 self
.menu_actions
['diff_commit'].setEnabled(has_single_selection_or_clicked
)
285 self
.menu_actions
['diff_commit_all'].setEnabled(has_single_selection_or_clicked
)
287 self
.menu_actions
['checkout_branch'].setEnabled(has_branches
)
288 self
.menu_actions
['checkout_detached'].setEnabled(
289 has_single_selection_or_clicked
291 self
.menu_actions
['cherry_pick'].setEnabled(has_single_selection_or_clicked
)
292 self
.menu_actions
['copy'].setEnabled(has_single_selection_or_clicked
)
293 self
.menu_actions
['create_branch'].setEnabled(has_single_selection_or_clicked
)
294 self
.menu_actions
['create_patch'].setEnabled(has_selection
)
295 self
.menu_actions
['create_tag'].setEnabled(has_single_selection_or_clicked
)
296 self
.menu_actions
['create_tarball'].setEnabled(has_single_selection_or_clicked
)
297 self
.menu_actions
['rebase_to_commit'].setEnabled(
298 has_single_selection_or_clicked
300 self
.menu_actions
['reset_mixed'].setEnabled(has_single_selection_or_clicked
)
301 self
.menu_actions
['reset_keep'].setEnabled(has_single_selection_or_clicked
)
302 self
.menu_actions
['reset_merge'].setEnabled(has_single_selection_or_clicked
)
303 self
.menu_actions
['reset_soft'].setEnabled(has_single_selection_or_clicked
)
304 self
.menu_actions
['reset_hard'].setEnabled(has_single_selection_or_clicked
)
305 self
.menu_actions
['restore_worktree'].setEnabled(
306 has_single_selection_or_clicked
308 self
.menu_actions
['revert'].setEnabled(has_single_selection_or_clicked
)
309 self
.menu_actions
['save_blob'].setEnabled(has_single_selection_or_clicked
)
311 def context_menu_event(self
, event
):
312 """Build a context menu and execute it"""
313 self
.update_menu_actions(event
)
314 menu
= qtutils
.create_menu(N_('Actions'), self
)
315 menu
.addAction(self
.menu_actions
['diff_this_selected'])
316 menu
.addAction(self
.menu_actions
['diff_selected_this'])
317 menu
.addAction(self
.menu_actions
['diff_commit'])
318 menu
.addAction(self
.menu_actions
['diff_commit_all'])
320 menu
.addAction(self
.menu_actions
['checkout_branch'])
321 menu
.addAction(self
.menu_actions
['create_branch'])
322 menu
.addAction(self
.menu_actions
['create_tag'])
323 menu
.addAction(self
.menu_actions
['rebase_to_commit'])
325 menu
.addAction(self
.menu_actions
['cherry_pick'])
326 menu
.addAction(self
.menu_actions
['revert'])
327 menu
.addAction(self
.menu_actions
['create_patch'])
328 menu
.addAction(self
.menu_actions
['create_tarball'])
330 reset_menu
= menu
.addMenu(N_('Reset'))
331 reset_menu
.addAction(self
.menu_actions
['reset_soft'])
332 reset_menu
.addAction(self
.menu_actions
['reset_mixed'])
333 reset_menu
.addAction(self
.menu_actions
['restore_worktree'])
334 reset_menu
.addSeparator()
335 reset_menu
.addAction(self
.menu_actions
['reset_keep'])
336 reset_menu
.addAction(self
.menu_actions
['reset_merge'])
337 reset_menu
.addAction(self
.menu_actions
['reset_hard'])
338 menu
.addAction(self
.menu_actions
['checkout_detached'])
340 menu
.addAction(self
.menu_actions
['save_blob'])
341 menu
.addAction(self
.menu_actions
['copy'])
342 menu
.exec_(self
.mapToGlobal(event
.pos()))
345 def set_icon(icon
, action
):
346 """ "Set the icon for an action and return the action"""
351 def viewer_actions(widget
):
352 """Return commont actions across the tree and graph widgets"""
354 'diff_this_selected': set_icon(
357 widget
, N_('Diff this -> selected'), widget
.proxy
.diff_this_selected
360 'diff_selected_this': set_icon(
363 widget
, N_('Diff selected -> this'), widget
.proxy
.diff_selected_this
366 'create_branch': set_icon(
368 qtutils
.add_action(widget
, N_('Create Branch'), widget
.proxy
.create_branch
),
370 'create_patch': set_icon(
372 qtutils
.add_action(widget
, N_('Create Patch'), widget
.proxy
.create_patch
),
374 'create_tag': set_icon(
376 qtutils
.add_action(widget
, N_('Create Tag'), widget
.proxy
.create_tag
),
378 'create_tarball': set_icon(
381 widget
, N_('Save As Tarball/Zip...'), widget
.proxy
.create_tarball
384 'cherry_pick': set_icon(
386 qtutils
.add_action(widget
, N_('Cherry Pick'), widget
.proxy
.cherry_pick
),
389 icons
.undo(), qtutils
.add_action(widget
, N_('Revert'), widget
.proxy
.revert
)
391 'diff_commit': set_icon(
394 widget
, N_('Launch Diff Tool'), widget
.proxy
.show_diff
, hotkeys
.DIFF
397 'diff_commit_all': set_icon(
401 N_('Launch Directory Diff Tool'),
402 widget
.proxy
.show_dir_diff
,
403 hotkeys
.DIFF_SECONDARY
,
406 'checkout_branch': set_icon(
409 widget
, N_('Checkout Branch'), widget
.proxy
.checkout_branch
412 'checkout_detached': qtutils
.add_action(
413 widget
, N_('Checkout Detached HEAD'), widget
.proxy
.checkout_detached
415 'rebase_to_commit': set_icon(
418 widget
, N_('Rebase to this commit'), widget
.proxy
.rebase_to_commit
421 'reset_soft': set_icon(
422 icons
.style_dialog_reset(),
424 widget
, N_('Reset Branch (Soft)'), widget
.proxy
.reset_soft
427 'reset_mixed': set_icon(
428 icons
.style_dialog_reset(),
430 widget
, N_('Reset Branch and Stage (Mixed)'), widget
.proxy
.reset_mixed
433 'reset_keep': set_icon(
434 icons
.style_dialog_reset(),
437 N_('Restore Worktree and Reset All (Keep Unstaged Edits)'),
438 widget
.proxy
.reset_keep
,
441 'reset_merge': set_icon(
442 icons
.style_dialog_reset(),
445 N_('Restore Worktree and Reset All (Merge)'),
446 widget
.proxy
.reset_merge
,
449 'reset_hard': set_icon(
450 icons
.style_dialog_reset(),
453 N_('Restore Worktree and Reset All (Hard)'),
454 widget
.proxy
.reset_hard
,
457 'restore_worktree': set_icon(
460 widget
, N_('Restore Worktree'), widget
.proxy
.restore_worktree
463 'save_blob': set_icon(
466 widget
, N_('Grab File...'), widget
.proxy
.save_blob_dialog
474 widget
.proxy
.copy_to_clipboard
,
481 class GitDagLineEdit(completion
.GitLogLineEdit
):
482 """The text input field for specifying "git log" options"""
484 def __init__(self
, context
):
485 super().__init
__(context
)
486 self
._action
_filter
_to
_current
_author
= qtutils
.add_action(
487 self
, N_('Commits authored by me'), self
._filter
_to
_current
_author
489 self
._action
_pickaxe
_search
= qtutils
.add_action(
490 self
, N_('Pickaxe search for changes containing text'), self
._pickaxe
_search
492 self
._action
_grep
_search
= qtutils
.add_action(
494 N_('Search commit messages'),
497 self
._action
_no
_merges
= qtutils
.add_action(
498 self
, N_('Ignore merge commits'), self
._no
_merges
501 def contextMenuEvent(self
, event
):
502 """Adds custom actions to the default context menu"""
503 event_pos
= event
.pos()
504 menu
= self
.createStandardContextMenu()
506 actions
= menu
.actions()
507 first_action
= actions
[0]
508 menu
.insertAction(first_action
, self
._action
_pickaxe
_search
)
509 menu
.insertAction(first_action
, self
._action
_filter
_to
_current
_author
)
510 menu
.insertAction(first_action
, self
._action
_grep
_search
)
511 menu
.insertAction(first_action
, self
._action
_no
_merges
)
512 menu
.insertSeparator(first_action
)
513 menu
.exec_(self
.mapToGlobal(event_pos
))
515 def insert(self
, text
):
516 """Insert text at the beginning of the current text"""
519 text
= f
'{text} {value}'
523 def _filter_to_current_author(self
):
524 """Filter to commits by the current author/user"""
525 _
, email
= self
.context
.cfg
.get_author()
526 author_filter
= '--author=' + email
527 self
.insert(author_filter
)
529 def _pickaxe_search(self
):
530 """Pickaxe search for changes containing text"""
531 self
.insert('-G"search"')
533 length
= len('search')
534 self
.setSelection(start
, length
)
536 def _grep_search(self
):
537 """Pickaxe search for changes containing text"""
538 self
.insert('--grep="search"')
539 start
= len('--grep="')
540 length
= len('search')
541 self
.setSelection(start
, length
)
543 def _no_merges(self
):
544 """Ignore merge commits"""
545 self
.insert('--no-merges')
548 class CommitTreeWidgetItem(QtWidgets
.QTreeWidgetItem
):
549 """Custom TreeWidgetItem used in to build the commit tree widget"""
551 def __init__(self
, commit
, parent
=None):
552 QtWidgets
.QTreeWidgetItem
.__init
__(self
, parent
)
554 self
.setText(0, commit
.summary
)
555 self
.setText(1, commit
.author
)
556 self
.setText(2, commit
.authdate
)
559 # pylint: disable=too-many-ancestors
560 class CommitTreeWidget(standard
.TreeWidget
, ViewerMixin
):
561 """Display commits using a flat treewidget in "list" mode"""
563 commits_selected
= Signal(object)
564 diff_commits
= Signal(object, object)
565 zoom_to_fit
= Signal()
567 def __init__(self
, context
, parent
):
568 standard
.TreeWidget
.__init
__(self
, parent
)
569 ViewerMixin
.__init
__(self
)
571 self
.setSelectionMode(self
.ExtendedSelection
)
572 self
.setHeaderLabels([N_('Summary'), N_('Author'), N_('Date, Time')])
574 self
.context
= context
576 self
.menu_actions
= None
577 self
.selecting
= False
579 self
._adjust
_columns
= False
581 self
.action_up
= qtutils
.add_action(
582 self
, N_('Go Up'), self
.go_up
, hotkeys
.MOVE_UP
585 self
.action_down
= qtutils
.add_action(
586 self
, N_('Go Down'), self
.go_down
, hotkeys
.MOVE_DOWN
589 self
.zoom_to_fit_action
= qtutils
.add_action(
590 self
, N_('Zoom to Fit'), self
.zoom_to_fit
.emit
, hotkeys
.FIT
593 # pylint: disable=no-member
594 self
.itemSelectionChanged
.connect(self
.selection_changed
)
596 def export_state(self
):
597 """Export the widget's state"""
598 # The base class method is intentionally overridden because we only
599 # care about the details below for this subwidget.
601 state
['column_widths'] = self
.column_widths()
604 def apply_state(self
, state
):
605 """Apply the exported widget state"""
607 column_widths
= state
['column_widths']
608 except (KeyError, ValueError):
611 self
.set_column_widths(column_widths
)
613 # Defer showing the columns until we are shown, and our true width
614 # is known. Calling adjust_columns() here ends up with the wrong
615 # answer because we have not yet been parented to the layout.
616 # We set this flag that we process once during our initial
618 self
._adjust
_columns
= True
622 def showEvent(self
, event
):
623 """Override QWidget::showEvent() to size columns when we are shown"""
624 if self
._adjust
_columns
:
625 self
._adjust
_columns
= False
627 two_thirds
= (width
* 2) // 3
628 one_sixth
= width
// 6
630 self
.setColumnWidth(0, two_thirds
)
631 self
.setColumnWidth(1, one_sixth
)
632 self
.setColumnWidth(2, one_sixth
)
633 return standard
.TreeWidget
.showEvent(self
, event
)
637 """Select the item above the current item"""
638 self
.goto(self
.itemAbove
)
641 """Select the item below the current item"""
642 self
.goto(self
.itemBelow
)
644 def goto(self
, finder
):
645 """Move the selection using a finder strategy"""
646 items
= self
.selected_items()
647 item
= items
[0] if items
else None
652 self
.select([found
.commit
.oid
])
654 def selected_commit_range(self
):
655 """Return a range of selected commits"""
656 selected_items
= self
.selected_items()
657 if not selected_items
:
659 return selected_items
[-1].commit
.oid
, selected_items
[0].commit
.oid
661 def set_selecting(self
, selecting
):
662 """Record the "are we selecting?" status"""
663 self
.selecting
= selecting
665 def selection_changed(self
):
666 """Respond to itemSelectionChanged notifications"""
667 items
= self
.selected_items()
669 self
.set_selecting(True)
670 self
.commits_selected
.emit([])
671 self
.set_selecting(False)
673 self
.set_selecting(True)
674 self
.commits_selected
.emit(sort_by_generation([i
.commit
for i
in items
]))
675 self
.set_selecting(False)
677 def select_commits(self
, commits
):
678 """Select commits that were selected by the sibling tree/graph widget"""
681 with qtutils
.BlockSignals(self
):
682 self
.select([commit
.oid
for commit
in commits
])
684 def select(self
, oids
):
685 """Mark items as selected"""
686 self
.clearSelection()
691 item
= self
.oidmap
[oid
]
694 self
.scrollToItem(item
)
695 item
.setSelected(True)
699 QtWidgets
.QTreeWidget
.clear(self
)
703 def add_commits(self
, commits
):
704 """Add commits to the tree"""
705 self
.commits
.extend(commits
)
707 for c
in reversed(commits
):
708 item
= CommitTreeWidgetItem(c
)
710 self
.oidmap
[c
.oid
] = item
712 self
.oidmap
[tag
] = item
713 self
.insertTopLevelItems(0, items
)
715 def create_patch(self
):
716 """Export a patch from the selected items"""
717 items
= self
.selectedItems()
720 context
= self
.context
721 oids
= [item
.commit
.oid
for item
in reversed(items
)]
722 all_oids
= [c
.oid
for c
in self
.commits
]
723 cmds
.do(cmds
.FormatPatch
, context
, oids
, all_oids
)
726 def contextMenuEvent(self
, event
):
727 """Create a custom context menu and execute it"""
728 self
.context_menu_event(event
)
730 def mousePressEvent(self
, event
):
731 """Intercept the right-click event to retain selection state"""
732 item
= self
.itemAt(event
.pos())
736 self
.clicked
= item
.commit
737 if event
.button() == Qt
.RightButton
:
740 QtWidgets
.QTreeWidget
.mousePressEvent(self
, event
)
743 class GitDAG(standard
.MainWindow
):
744 """The git-dag widget."""
746 commits_selected
= Signal(object)
748 def __init__(self
, context
, params
, parent
=None):
749 super().__init
__(parent
)
751 self
.setMinimumSize(420, 420)
753 # change when widgets are added/removed
754 self
.widget_version
= 2
755 self
.context
= context
757 self
.model
= context
.model
760 self
.commit_list
= []
762 self
.old_refs
= set()
765 self
.force_refresh
= False
768 self
.revtext
= GitDagLineEdit(context
)
769 self
.maxresults
= standard
.SpinBox()
771 self
.zoom_out
= qtutils
.create_action_button(
772 tooltip
=N_('Zoom Out'), icon
=icons
.zoom_out()
775 self
.zoom_in
= qtutils
.create_action_button(
776 tooltip
=N_('Zoom In'), icon
=icons
.zoom_in()
779 self
.zoom_to_fit
= qtutils
.create_action_button(
780 tooltip
=N_('Zoom to Fit'), icon
=icons
.zoom_fit_best()
783 self
.treewidget
= CommitTreeWidget(context
, self
)
784 self
.diffwidget
= diff
.DiffWidget(context
, self
, is_commit
=True)
785 self
.filewidget
= filelist
.FileWidget(context
, self
)
786 self
.graphview
= GraphView(context
, self
)
788 self
.treewidget
.commits_selected
.connect(self
.commits_selected
)
789 self
.graphview
.commits_selected
.connect(self
.commits_selected
)
791 self
.commits_selected
.connect(self
.select_commits
)
792 self
.commits_selected
.connect(self
.diffwidget
.commits_selected
)
793 self
.commits_selected
.connect(self
.filewidget
.commits_selected
)
794 self
.commits_selected
.connect(self
.graphview
.select_commits
)
795 self
.commits_selected
.connect(self
.treewidget
.select_commits
)
797 self
.filewidget
.files_selected
.connect(self
.diffwidget
.files_selected
)
798 self
.filewidget
.difftool_selected
.connect(self
.difftool_selected
)
799 self
.filewidget
.histories_selected
.connect(self
.histories_selected
)
801 self
.proxy
= FocusRedirectProxy(
802 self
.treewidget
, self
.graphview
, self
.filewidget
805 self
.viewer_actions
= actions
= viewer_actions(self
)
806 self
.treewidget
.menu_actions
= actions
807 self
.graphview
.menu_actions
= actions
809 self
.controls_layout
= qtutils
.hbox(
810 defs
.no_margin
, defs
.spacing
, self
.revtext
, self
.maxresults
813 self
.controls_widget
= QtWidgets
.QWidget()
814 self
.controls_widget
.setLayout(self
.controls_layout
)
816 self
.log_dock
= qtutils
.create_dock('Log', N_('Log'), self
, stretch
=False)
817 self
.log_dock
.setWidget(self
.treewidget
)
818 log_dock_titlebar
= self
.log_dock
.titleBarWidget()
819 log_dock_titlebar
.add_corner_widget(self
.controls_widget
)
821 self
.file_dock
= qtutils
.create_dock('Files', N_('Files'), self
)
822 self
.file_dock
.setWidget(self
.filewidget
)
824 self
.diff_panel
= diff
.DiffPanel(self
.diffwidget
, self
.diffwidget
.diff
, self
)
825 self
.diff_options
= diff
.Options(self
.diffwidget
)
826 self
.diffwidget
.set_options(self
.diff_options
)
827 self
.diff_options
.hide_advanced_options()
828 self
.diff_options
.set_diff_type(main
.Types
.TEXT
)
830 self
.diff_dock
= qtutils
.create_dock('Diff', N_('Diff'), self
)
831 self
.diff_dock
.setWidget(self
.diff_panel
)
833 diff_titlebar
= self
.diff_dock
.titleBarWidget()
834 diff_titlebar
.add_corner_widget(self
.diff_options
)
836 self
.graph_controls_layout
= qtutils
.hbox(
845 self
.graph_controls_widget
= QtWidgets
.QWidget()
846 self
.graph_controls_widget
.setLayout(self
.graph_controls_layout
)
848 self
.graphview_dock
= qtutils
.create_dock('Graph', N_('Graph'), self
)
849 self
.graphview_dock
.setWidget(self
.graphview
)
850 graph_titlebar
= self
.graphview_dock
.titleBarWidget()
851 graph_titlebar
.add_corner_widget(self
.graph_controls_widget
)
853 self
.lock_layout_action
= qtutils
.add_action_bool(
854 self
, N_('Lock Layout'), self
.set_lock_layout
, False
857 self
.refresh_action
= qtutils
.add_action(
858 self
, N_('Refresh'), self
.refresh
, hotkeys
.REFRESH
861 # Create the application menu
862 self
.menubar
= QtWidgets
.QMenuBar(self
)
863 self
.setMenuBar(self
.menubar
)
866 self
.view_menu
= qtutils
.add_menu(N_('View'), self
.menubar
)
867 self
.view_menu
.addAction(self
.refresh_action
)
868 self
.view_menu
.addAction(self
.log_dock
.toggleViewAction())
869 self
.view_menu
.addAction(self
.graphview_dock
.toggleViewAction())
870 self
.view_menu
.addAction(self
.diff_dock
.toggleViewAction())
871 self
.view_menu
.addAction(self
.file_dock
.toggleViewAction())
872 self
.view_menu
.addSeparator()
873 self
.view_menu
.addAction(self
.lock_layout_action
)
875 left
= Qt
.LeftDockWidgetArea
876 right
= Qt
.RightDockWidgetArea
877 self
.addDockWidget(left
, self
.log_dock
)
878 self
.addDockWidget(left
, self
.diff_dock
)
879 self
.addDockWidget(right
, self
.graphview_dock
)
880 self
.addDockWidget(right
, self
.file_dock
)
882 # Also re-loads dag.* from the saved state
883 self
.init_state(context
.settings
, self
.resize_to_desktop
)
885 qtutils
.connect_button(self
.zoom_out
, self
.graphview
.zoom_out
)
886 qtutils
.connect_button(self
.zoom_in
, self
.graphview
.zoom_in
)
887 qtutils
.connect_button(self
.zoom_to_fit
, self
.graphview
.zoom_to_fit
)
889 self
.treewidget
.zoom_to_fit
.connect(self
.graphview
.zoom_to_fit
)
890 self
.treewidget
.diff_commits
.connect(self
.diff_commits
)
891 self
.graphview
.diff_commits
.connect(self
.diff_commits
)
892 self
.filewidget
.grab_file
.connect(self
.grab_file
)
894 # pylint: disable=no-member
895 self
.maxresults
.editingFinished
.connect(self
.display
)
897 self
.revtext
.textChanged
.connect(self
.text_changed
)
898 self
.revtext
.activated
.connect(self
.display
)
899 self
.revtext
.enter
.connect(self
.display
)
900 self
.revtext
.down
.connect(self
.focus_tree
)
902 # The model is updated in another thread so use
903 # signals/slots to bring control back to the main GUI thread
904 self
.model
.updated
.connect(self
.model_updated
, type=Qt
.QueuedConnection
)
906 qtutils
.add_action(self
, 'FocusInput', self
.focus_input
, hotkeys
.FOCUS_INPUT
)
907 qtutils
.add_action(self
, 'FocusTree', self
.focus_tree
, hotkeys
.FOCUS_TREE
)
908 qtutils
.add_action(self
, 'FocusDiff', self
.focus_diff
, hotkeys
.FOCUS_DIFF
)
909 qtutils
.add_close_action(self
)
911 self
.set_params(params
)
913 def set_params(self
, params
):
914 context
= self
.context
917 # Update fields affected by model
918 self
.revtext
.setText(params
.ref
)
919 self
.maxresults
.setValue(params
.count
)
920 self
.update_window_title()
922 if self
.thread
is not None:
925 self
.thread
= ReaderThread(context
, params
, self
)
928 thread
.begin
.connect(self
.thread_begin
, type=Qt
.QueuedConnection
)
929 thread
.status
.connect(self
.thread_status
, type=Qt
.QueuedConnection
)
930 thread
.add
.connect(self
.add_commits
, type=Qt
.QueuedConnection
)
931 thread
.end
.connect(self
.thread_end
, type=Qt
.QueuedConnection
)
933 def focus_input(self
):
934 """Focus the revision input field"""
935 self
.revtext
.setFocus()
937 def focus_tree(self
):
938 """Focus the revision tree list widget"""
939 self
.treewidget
.setFocus()
941 def focus_diff(self
):
942 """Focus the diff widget"""
943 self
.diffwidget
.setFocus()
945 def text_changed(self
, txt
):
946 self
.params
.ref
= txt
947 self
.update_window_title()
949 def update_window_title(self
):
950 project
= self
.model
.project
953 N_('%(project)s: %(ref)s - DAG')
956 'ref': self
.params
.ref
,
960 self
.setWindowTitle(project
+ N_(' - DAG'))
962 def export_state(self
):
963 state
= standard
.MainWindow
.export_state(self
)
964 state
['count'] = self
.params
.count
965 state
['log'] = self
.treewidget
.export_state()
966 state
['word_wrap'] = self
.diffwidget
.options
.enable_word_wrapping
.isChecked()
969 def apply_state(self
, state
):
970 result
= standard
.MainWindow
.apply_state(self
, state
)
972 count
= state
['count']
973 if self
.params
.overridden('count'):
974 count
= self
.params
.count
975 except (KeyError, TypeError, ValueError, AttributeError):
976 count
= self
.params
.count
978 self
.params
.set_count(count
)
979 self
.lock_layout_action
.setChecked(state
.get('lock_layout', False))
980 self
.diffwidget
.set_word_wrapping(state
.get('word_wrap', False), update
=True)
983 log_state
= state
['log']
984 except (KeyError, ValueError):
987 self
.treewidget
.apply_state(log_state
)
991 def model_updated(self
):
993 self
.update_window_title()
996 """Unconditionally refresh the DAG"""
997 # self.force_refresh triggers an Unconditional redraw
998 self
.force_refresh
= True
999 cmds
.do(cmds
.Refresh
, self
.context
)
1002 """Update the view when the Git refs change"""
1003 ref
= get(self
.revtext
)
1004 count
= get(self
.maxresults
)
1005 context
= self
.context
1007 # The DAG tries to avoid updating when the object IDs have not
1008 # changed. Without doing this the DAG constantly redraws itself
1009 # whenever inotify sends update events, which hurts usability.
1011 # To minimize redraws we leverage `git rev-parse`. The strategy is to
1012 # use `git rev-parse` on the input line, which converts each argument
1013 # into object IDs. From there it's a simple matter of detecting when
1014 # the object IDs changed.
1016 # In addition to object IDs, we also need to know when the set of
1017 # named references (branches, tags) changes so that an update is
1018 # triggered when new branches and tags are created.
1019 refs
= set(model
.local_branches
+ model
.remote_branches
+ model
.tags
)
1020 argv
= utils
.shell_split(ref
or 'HEAD')
1021 oids
= gitcmds
.parse_refs(context
, argv
)
1024 or count
!= self
.old_count
1025 or oids
!= self
.old_oids
1026 or refs
!= self
.old_refs
1030 self
.params
.set_ref(ref
)
1031 self
.params
.set_count(count
)
1034 self
.old_oids
= oids
1035 self
.old_count
= count
1036 self
.old_refs
= refs
1037 self
.force_refresh
= False
1039 def select_commits(self
, commits
):
1040 self
.selection
= commits
1043 self
.commits
.clear()
1044 self
.commit_list
= []
1045 self
.graphview
.clear()
1046 self
.treewidget
.clear()
1048 def add_commits(self
, commits
):
1049 self
.commit_list
.extend(commits
)
1050 # Keep track of commits
1051 for commit_obj
in commits
:
1052 self
.commits
[commit_obj
.oid
] = commit_obj
1053 for tag
in commit_obj
.tags
:
1054 self
.commits
[tag
] = commit_obj
1055 self
.graphview
.add_commits(commits
)
1056 self
.treewidget
.add_commits(commits
)
1058 def thread_begin(self
):
1061 def thread_end(self
):
1062 self
.restore_selection()
1064 def thread_status(self
, successful
):
1065 self
.revtext
.hint
.set_error(not successful
)
1067 def restore_selection(self
):
1068 selection
= self
.selection
1070 commit_obj
= self
.commit_list
[-1]
1072 # No commits, exist, early-out
1075 new_commits
= [self
.commits
.get(s
.oid
, None) for s
in selection
]
1076 new_commits
= [c
for c
in new_commits
if c
is not None]
1078 # The old selection exists in the new state
1079 self
.commits_selected
.emit(sort_by_generation(new_commits
))
1081 # The old selection is now empty. Select the top-most commit
1082 self
.commits_selected
.emit([commit_obj
])
1084 self
.graphview
.set_initial_view()
1086 def diff_commits(self
, left
, right
):
1087 paths
= self
.params
.paths()
1089 difftool
.difftool_launch(self
.context
, left
=left
, right
=right
, paths
=paths
)
1091 difftool
.diff_commits(self
.context
, self
, left
, right
)
1094 def closeEvent(self
, event
):
1095 self
.revtext
.close_popup()
1097 standard
.MainWindow
.closeEvent(self
, event
)
1099 def histories_selected(self
, histories
):
1100 argv
= [self
.model
.currentbranch
, '--']
1101 argv
.extend(histories
)
1102 rev_text
= core
.list2cmdline(argv
)
1103 self
.revtext
.setText(rev_text
)
1106 def difftool_selected(self
, files
):
1107 bottom
, top
= self
.treewidget
.selected_commit_range()
1110 difftool
.difftool_launch(
1111 self
.context
, left
=bottom
, left_take_parent
=True, right
=top
, paths
=files
1114 def grab_file(self
, filename
):
1115 """Save the selected file from the filelist widget"""
1116 oid
= self
.treewidget
.selected_oid()
1117 model
= browse
.BrowseModel(oid
, filename
=filename
)
1118 browse
.save_path(self
.context
, filename
, model
)
1121 class ReaderThread(QtCore
.QThread
):
1123 add
= Signal(object)
1125 status
= Signal(object)
1127 def __init__(self
, context
, params
, parent
):
1128 QtCore
.QThread
.__init
__(self
, parent
)
1129 self
.context
= context
1130 self
.params
= params
1133 self
._mutex
= QtCore
.QMutex()
1134 self
._condition
= QtCore
.QWaitCondition()
1137 context
= self
.context
1138 repo
= dag
.RepoReader(context
, self
.params
)
1142 for commit
in repo
.get():
1145 self
._condition
.wait(self
._mutex
)
1146 self
._mutex
.unlock()
1150 commits
.append(commit
)
1151 if len(commits
) >= 512:
1152 self
.add
.emit(commits
)
1155 self
.status
.emit(repo
.returncode
== 0)
1157 self
.add
.emit(commits
)
1163 QtCore
.QThread
.start(self
)
1168 self
._mutex
.unlock()
1173 self
._mutex
.unlock()
1174 self
._condition
.wakeOne()
1185 def label_font(cls
):
1186 font
= cls
._label
_font
1188 font
= cls
._label
_font
= QtWidgets
.QApplication
.font()
1189 font
.setPointSize(6)
1193 class Edge(QtWidgets
.QGraphicsItem
):
1194 item_type
= qtutils
.standard_item_type_value(1)
1196 def __init__(self
, source
, dest
):
1197 QtWidgets
.QGraphicsItem
.__init
__(self
)
1199 self
.setAcceptedMouseButtons(Qt
.NoButton
)
1200 self
.source
= source
1202 self
.commit
= source
.commit
1205 self
.recompute_bound()
1207 self
.path_valid
= False
1209 # Choose a new color for new branch edges
1210 if self
.source
.x() < self
.dest
.x():
1211 color
= EdgeColor
.cycle()
1213 elif self
.source
.x() != self
.dest
.x():
1214 color
= EdgeColor
.current()
1217 color
= EdgeColor
.current()
1220 self
.pen
= QtGui
.QPen(color
, 2.0, line
, Qt
.SquareCap
, Qt
.RoundJoin
)
1222 def recompute_bound(self
):
1223 dest_pt
= Commit
.item_bbox
.center()
1225 self
.source_pt
= self
.mapFromItem(self
.source
, dest_pt
)
1226 self
.dest_pt
= self
.mapFromItem(self
.dest
, dest_pt
)
1227 self
.line
= QtCore
.QLineF(self
.source_pt
, self
.dest_pt
)
1229 width
= self
.dest_pt
.x() - self
.source_pt
.x()
1230 height
= self
.dest_pt
.y() - self
.source_pt
.y()
1231 rect
= QtCore
.QRectF(self
.source_pt
, QtCore
.QSizeF(width
, height
))
1232 self
.bound
= rect
.normalized()
1234 def commits_were_invalidated(self
):
1235 self
.recompute_bound()
1236 self
.prepareGeometryChange()
1237 # The path should not be recomputed immediately because just small part
1238 # of DAG is actually shown at same time. It will be recomputed on
1239 # demand in course of 'paint' method.
1240 self
.path_valid
= False
1241 # Hence, just queue redrawing.
1246 return self
.item_type
1248 def boundingRect(self
):
1251 def recompute_path(self
):
1252 QRectF
= QtCore
.QRectF
1253 QPointF
= QtCore
.QPointF
1256 connector_length
= 5
1258 path
= QtGui
.QPainterPath()
1260 if self
.source
.x() == self
.dest
.x():
1261 path
.moveTo(self
.source
.x(), self
.source
.y())
1262 path
.lineTo(self
.dest
.x(), self
.dest
.y())
1264 # Define points starting from source
1265 point1
= QPointF(self
.source
.x(), self
.source
.y())
1266 point2
= QPointF(point1
.x(), point1
.y() - connector_length
)
1267 point3
= QPointF(point2
.x() + arc_rect
, point2
.y() - arc_rect
)
1269 # Define points starting from dest
1270 point4
= QPointF(self
.dest
.x(), self
.dest
.y())
1271 point5
= QPointF(point4
.x(), point3
.y() - arc_rect
)
1272 point6
= QPointF(point5
.x() - arc_rect
, point5
.y() + arc_rect
)
1274 start_angle_arc1
= 180
1275 span_angle_arc1
= 90
1276 start_angle_arc2
= 90
1277 span_angle_arc2
= -90
1279 # If the dest is at the left of the source, then we
1280 # need to reverse some values
1281 if self
.source
.x() > self
.dest
.x():
1282 point3
= QPointF(point2
.x() - arc_rect
, point3
.y())
1283 point6
= QPointF(point5
.x() + arc_rect
, point6
.y())
1285 span_angle_arc1
= 90
1289 path
.arcTo(QRectF(point2
, point3
), start_angle_arc1
, span_angle_arc1
)
1291 path
.arcTo(QRectF(point6
, point5
), start_angle_arc2
, span_angle_arc2
)
1295 self
.path_valid
= True
1297 def paint(self
, painter
, _option
, _widget
):
1298 if not self
.path_valid
:
1299 self
.recompute_path()
1300 painter
.setPen(self
.pen
)
1301 painter
.drawPath(self
.path
)
1305 """An edge color factory"""
1307 current_color_index
= 0
1309 QtGui
.QColor(Qt
.red
),
1310 QtGui
.QColor(Qt
.cyan
),
1311 QtGui
.QColor(Qt
.magenta
),
1312 QtGui
.QColor(Qt
.green
),
1313 # Orange; Qt.yellow is too low-contrast
1314 qtutils
.rgba(0xFF, 0x66, 0x00),
1318 def update_colors(cls
, theme
):
1319 """Update the colors based on the color theme"""
1320 if theme
.is_dark
or theme
.is_palette_dark
:
1322 QtGui
.QColor(Qt
.red
).lighter(),
1323 QtGui
.QColor(Qt
.cyan
).lighter(),
1324 QtGui
.QColor(Qt
.magenta
).lighter(),
1325 QtGui
.QColor(Qt
.green
).lighter(),
1326 QtGui
.QColor(Qt
.yellow
).lighter(),
1330 QtGui
.QColor(Qt
.blue
),
1331 QtGui
.QColor(Qt
.darkRed
),
1332 QtGui
.QColor(Qt
.darkCyan
),
1333 QtGui
.QColor(Qt
.darkMagenta
),
1334 QtGui
.QColor(Qt
.darkGreen
),
1335 QtGui
.QColor(Qt
.darkYellow
),
1336 QtGui
.QColor(Qt
.darkBlue
),
1341 cls
.current_color_index
+= 1
1342 cls
.current_color_index
%= len(cls
.colors
)
1343 color
= cls
.colors
[cls
.current_color_index
]
1349 return cls
.colors
[cls
.current_color_index
]
1353 cls
.current_color_index
= 0
1356 class Commit(QtWidgets
.QGraphicsItem
):
1357 item_type
= qtutils
.standard_item_type_value(2)
1358 commit_radius
= 12.0
1361 item_shape
= QtGui
.QPainterPath()
1363 commit_radius
/ -2.0, commit_radius
/ -2.0, commit_radius
, commit_radius
1365 item_bbox
= item_shape
.boundingRect()
1367 inner_rect
= QtGui
.QPainterPath()
1369 commit_radius
/ -2.0 + 2.0,
1370 commit_radius
/ -2.0 + 2.0,
1371 commit_radius
- 4.0,
1372 commit_radius
- 4.0,
1374 inner_rect
= inner_rect
.boundingRect()
1376 commit_color
= QtGui
.QColor(Qt
.white
)
1377 outline_color
= commit_color
.darker()
1378 merge_color
= QtGui
.QColor(Qt
.lightGray
)
1380 commit_selected_color
= QtGui
.QColor(Qt
.green
)
1381 selected_outline_color
= commit_selected_color
.darker()
1383 commit_pen
= QtGui
.QPen()
1384 commit_pen
.setWidth(1)
1385 commit_pen
.setColor(outline_color
)
1390 selectable
=QtWidgets
.QGraphicsItem
.ItemIsSelectable
,
1391 cursor
=Qt
.PointingHandCursor
,
1392 xpos
=commit_radius
/ 2.0 + 1.0,
1393 cached_commit_color
=commit_color
,
1394 cached_merge_color
=merge_color
,
1396 QtWidgets
.QGraphicsItem
.__init
__(self
)
1398 self
.commit
= commit
1399 self
.selected
= False
1402 self
.setFlag(selectable
)
1403 self
.setCursor(cursor
)
1404 self
.setToolTip(commit
.oid
[:12] + ': ' + commit
.summary
)
1407 self
.label
= label
= Label(commit
)
1408 label
.setParentItem(self
)
1409 label
.setPos(xpos
+ 1, -self
.commit_radius
/ 2.0)
1413 if len(commit
.parents
) > 1:
1414 self
.brush
= cached_merge_color
1416 self
.brush
= cached_commit_color
1418 self
.pressed
= False
1419 self
.dragged
= False
1422 def itemChange(self
, change
, value
):
1423 if change
== QtWidgets
.QGraphicsItem
.ItemSelectedHasChanged
:
1424 # Cache the pen for use in paint()
1426 self
.brush
= self
.commit_selected_color
1427 color
= self
.selected_outline_color
1429 if len(self
.commit
.parents
) > 1:
1430 self
.brush
= self
.merge_color
1432 self
.brush
= self
.commit_color
1433 color
= self
.outline_color
1434 commit_pen
= QtGui
.QPen()
1435 commit_pen
.setWidth(1)
1436 commit_pen
.setColor(color
)
1437 self
.commit_pen
= commit_pen
1439 return QtWidgets
.QGraphicsItem
.itemChange(self
, change
, value
)
1442 return self
.item_type
1444 def boundingRect(self
):
1445 return self
.item_bbox
1448 return self
.item_shape
1450 def paint(self
, painter
, option
, _widget
):
1451 # Do not draw outside the exposed rect
1452 painter
.setClipRect(option
.exposedRect
)
1455 painter
.setPen(self
.commit_pen
)
1456 painter
.setBrush(self
.brush
)
1457 painter
.drawEllipse(self
.inner_rect
)
1459 def mousePressEvent(self
, event
):
1460 QtWidgets
.QGraphicsItem
.mousePressEvent(self
, event
)
1462 self
.selected
= self
.isSelected()
1464 def mouseMoveEvent(self
, event
):
1467 QtWidgets
.QGraphicsItem
.mouseMoveEvent(self
, event
)
1469 def mouseReleaseEvent(self
, event
):
1470 QtWidgets
.QGraphicsItem
.mouseReleaseEvent(self
, event
)
1471 if not self
.dragged
and self
.selected
and event
.button() == Qt
.LeftButton
:
1473 self
.pressed
= False
1474 self
.dragged
= False
1477 class Label(QtWidgets
.QGraphicsItem
):
1478 item_type
= qtutils
.graphics_item_type_value(3)
1480 head_color
= QtGui
.QColor(Qt
.green
)
1481 other_color
= QtGui
.QColor(Qt
.white
)
1482 remote_color
= QtGui
.QColor(Qt
.yellow
)
1484 head_pen
= QtGui
.QPen()
1485 head_pen
.setColor(QtGui
.QColor(Qt
.black
))
1486 head_pen
.setWidth(1)
1488 text_pen
= QtGui
.QPen()
1489 text_pen
.setColor(QtGui
.QColor(Qt
.black
))
1490 text_pen
.setWidth(1)
1497 def __init__(self
, commit
):
1498 QtWidgets
.QGraphicsItem
.__init
__(self
)
1500 self
.commit
= commit
1503 return self
.item_type
1505 def boundingRect(self
, cache
=Cache
):
1506 QPainterPath
= QtGui
.QPainterPath
1507 QRectF
= QtCore
.QRectF
1512 spacing
= self
.item_spacing
1513 border_x
= self
.border
+ self
.text_x_offset
1514 border_y
= self
.border
+ self
.text_y_offset
1516 font
= cache
.label_font()
1517 item_shape
= QPainterPath()
1519 base_rect
= QRectF(0, 0, width
, height
)
1520 base_rect
= base_rect
.adjusted(-border_x
, -border_y
, border_x
, border_y
)
1521 item_shape
.addRect(base_rect
)
1523 for tag
in self
.commit
.tags
:
1524 text_shape
= QPainterPath()
1525 text_shape
.addText(current_width
, 0, font
, tag
)
1526 text_rect
= text_shape
.boundingRect()
1527 box_rect
= text_rect
.adjusted(-border_x
, -border_y
, border_x
, border_y
)
1528 item_shape
.addRect(box_rect
)
1529 current_width
= item_shape
.boundingRect().width() + spacing
1531 return item_shape
.boundingRect()
1533 def paint(self
, painter
, _option
, _widget
, cache
=Cache
):
1534 # Draw tags and branches
1535 font
= cache
.label_font()
1536 painter
.setFont(font
)
1539 border
= self
.border
1540 x_offset
= self
.text_x_offset
1541 y_offset
= self
.text_y_offset
1542 spacing
= self
.item_spacing
1543 QRectF
= QtCore
.QRectF
1546 remotes_prefix
= 'remotes/'
1547 tags_prefix
= 'tags/'
1548 heads_prefix
= 'heads/'
1549 remotes_len
= len(remotes_prefix
)
1550 tags_len
= len(tags_prefix
)
1551 heads_len
= len(heads_prefix
)
1553 for tag
in self
.commit
.tags
:
1555 painter
.setPen(self
.text_pen
)
1556 painter
.setBrush(self
.remote_color
)
1557 elif tag
.startswith(remotes_prefix
):
1558 tag
= tag
[remotes_len
:]
1559 painter
.setPen(self
.text_pen
)
1560 painter
.setBrush(self
.other_color
)
1561 elif tag
.startswith(tags_prefix
):
1562 tag
= tag
[tags_len
:]
1563 painter
.setPen(self
.text_pen
)
1564 painter
.setBrush(self
.remote_color
)
1565 elif tag
.startswith(heads_prefix
):
1566 tag
= tag
[heads_len
:]
1567 painter
.setPen(self
.head_pen
)
1568 painter
.setBrush(self
.head_color
)
1570 painter
.setPen(self
.text_pen
)
1571 painter
.setBrush(self
.other_color
)
1573 text_rect
= painter
.boundingRect(
1574 QRectF(current_width
, 0, 0, 0), Qt
.TextSingleLine
, tag
1576 box_rect
= text_rect
.adjusted(-x_offset
, -y_offset
, x_offset
, y_offset
)
1578 painter
.drawRoundedRect(box_rect
, border
, border
)
1579 painter
.drawText(text_rect
, Qt
.TextSingleLine
, tag
)
1580 current_width
+= text_rect
.width() + spacing
1583 # pylint: disable=too-many-ancestors
1584 class GraphView(QtWidgets
.QGraphicsView
, ViewerMixin
):
1585 commits_selected
= Signal(object)
1586 diff_commits
= Signal(object, object)
1588 x_adjust
= int(Commit
.commit_radius
* 4 / 3)
1589 y_adjust
= int(Commit
.commit_radius
* 4 / 3)
1594 def __init__(self
, context
, parent
):
1595 QtWidgets
.QGraphicsView
.__init
__(self
, parent
)
1596 ViewerMixin
.__init
__(self
)
1597 EdgeColor
.update_colors(context
.app
.theme
)
1599 theme
= context
.app
.theme
1600 highlight
= theme
.selection_color()
1601 Commit
.commit_selected_color
= highlight
1602 Commit
.selected_outline_color
= highlight
.darker()
1604 self
.context
= context
1606 self
.menu_actions
= None
1609 self
.mouse_start
= [0, 0]
1610 self
.saved_matrix
= self
.transform()
1614 self
.tagged_cells
= set()
1618 self
.x_offsets
= collections
.defaultdict(lambda: self
.x_min
)
1620 self
.is_panning
= False
1621 self
.pressed
= False
1622 self
.selecting
= False
1623 self
.last_mouse
= [0, 0]
1625 self
.setDragMode(self
.RubberBandDrag
)
1627 scene
= QtWidgets
.QGraphicsScene(self
)
1628 scene
.setItemIndexMethod(QtWidgets
.QGraphicsScene
.BspTreeIndex
)
1629 self
.setScene(scene
)
1631 # pylint: disable=no-member
1632 scene
.selectionChanged
.connect(self
.selection_changed
)
1634 self
.setRenderHint(QtGui
.QPainter
.Antialiasing
)
1635 self
.setViewportUpdateMode(self
.SmartViewportUpdate
)
1636 self
.setCacheMode(QtWidgets
.QGraphicsView
.CacheBackground
)
1637 self
.setTransformationAnchor(QtWidgets
.QGraphicsView
.AnchorUnderMouse
)
1638 self
.setResizeAnchor(QtWidgets
.QGraphicsView
.NoAnchor
)
1640 background_color
= qtutils
.css_color(context
.app
.theme
.background_color_rgb())
1641 self
.setBackgroundBrush(background_color
)
1648 hotkeys
.ZOOM_IN_SECONDARY
,
1651 qtutils
.add_action(self
, N_('Zoom Out'), self
.zoom_out
, hotkeys
.ZOOM_OUT
)
1653 qtutils
.add_action(self
, N_('Zoom to Fit'), self
.zoom_to_fit
, hotkeys
.FIT
)
1656 self
, N_('Select Parent'), self
._select
_parent
, hotkeys
.MOVE_DOWN_TERTIARY
1661 N_('Select Oldest Parent'),
1662 self
._select
_oldest
_parent
,
1667 self
, N_('Select Child'), self
._select
_child
, hotkeys
.MOVE_UP_TERTIARY
1671 self
, N_('Select Newest Child'), self
._select
_newest
_child
, hotkeys
.MOVE_UP
1676 self
.scene().clear()
1678 self
.x_offsets
.clear()
1682 # ViewerMixin interface
1683 def selected_items(self
):
1684 """Return the currently selected items"""
1685 return self
.scene().selectedItems()
1688 self
.scale_view(1.5)
1691 self
.scale_view(1.0 / 1.5)
1693 def selection_changed(self
):
1694 # Broadcast selection to other widgets
1695 selected_items
= self
.scene().selectedItems()
1696 commits
= sort_by_generation([item
.commit
for item
in selected_items
])
1697 self
.set_selecting(True)
1698 self
.commits_selected
.emit(commits
)
1699 self
.set_selecting(False)
1701 def select_commits(self
, commits
):
1704 with qtutils
.BlockSignals(self
.scene()):
1705 self
.select([commit
.oid
for commit
in commits
])
1707 def select(self
, oids
):
1708 """Select the item for the oids"""
1709 self
.scene().clearSelection()
1712 item
= self
.items
[oid
]
1715 item
.setSelected(True)
1716 item_rect
= item
.sceneTransform().mapRect(item
.boundingRect())
1717 self
.ensureVisible(item_rect
)
1719 def _get_item_by_generation(self
, commits
, criteria_func
):
1720 """Return the item for the commit matching criteria"""
1724 for commit
in commits
:
1725 if generation
is None or criteria_func(generation
, commit
.generation
):
1727 generation
= commit
.generation
1729 return self
.items
[oid
]
1733 def _oldest_item(self
, commits
):
1734 """Return the item for the commit with the oldest generation number"""
1735 return self
._get
_item
_by
_generation
(commits
, lambda a
, b
: a
> b
)
1737 def _newest_item(self
, commits
):
1738 """Return the item for the commit with the newest generation number"""
1739 return self
._get
_item
_by
_generation
(commits
, lambda a
, b
: a
< b
)
1741 def create_patch(self
):
1742 items
= self
.selected_items()
1745 context
= self
.context
1746 selected_commits
= sort_by_generation([n
.commit
for n
in items
])
1747 oids
= [c
.oid
for c
in selected_commits
]
1748 all_oids
= [c
.oid
for c
in sort_by_generation(self
.commits
)]
1749 cmds
.do(cmds
.FormatPatch
, context
, oids
, all_oids
)
1751 def _select_parent(self
):
1752 """Select the parent with the newest generation number"""
1753 selected_item
= self
.selected_item()
1754 if selected_item
is None:
1756 parent_item
= self
._newest
_item
(selected_item
.commit
.parents
)
1757 if parent_item
is None:
1759 selected_item
.setSelected(False)
1760 parent_item
.setSelected(True)
1761 self
.ensureVisible(parent_item
.mapRectToScene(parent_item
.boundingRect()))
1763 def _select_oldest_parent(self
):
1764 """Select the parent with the oldest generation number"""
1765 selected_item
= self
.selected_item()
1766 if selected_item
is None:
1768 parent_item
= self
._oldest
_item
(selected_item
.commit
.parents
)
1769 if parent_item
is None:
1771 selected_item
.setSelected(False)
1772 parent_item
.setSelected(True)
1773 scene_rect
= parent_item
.mapRectToScene(parent_item
.boundingRect())
1774 self
.ensureVisible(scene_rect
)
1776 def _select_child(self
):
1777 """Select the child with the oldest generation number"""
1778 selected_item
= self
.selected_item()
1779 if selected_item
is None:
1781 child_item
= self
._oldest
_item
(selected_item
.commit
.children
)
1782 if child_item
is None:
1784 selected_item
.setSelected(False)
1785 child_item
.setSelected(True)
1786 scene_rect
= child_item
.mapRectToScene(child_item
.boundingRect())
1787 self
.ensureVisible(scene_rect
)
1789 def _select_newest_child(self
):
1790 """Select the Nth child with the newest generation number (N > 1)"""
1791 selected_item
= self
.selected_item()
1792 if selected_item
is None:
1794 if len(selected_item
.commit
.children
) > 1:
1795 children
= selected_item
.commit
.children
[1:]
1797 children
= selected_item
.commit
.children
1798 child_item
= self
._newest
_item
(children
)
1799 if child_item
is None:
1801 selected_item
.setSelected(False)
1802 child_item
.setSelected(True)
1803 scene_rect
= child_item
.mapRectToScene(child_item
.boundingRect())
1804 self
.ensureVisible(scene_rect
)
1806 def set_initial_view(self
):
1808 selected
= self
.selected_items()
1810 items
.extend(selected
)
1812 if not selected
and self
.commits
:
1813 commit
= self
.commits
[-1]
1814 items
.append(self
.items
[commit
.oid
])
1816 bounds
= self
.scene().itemsBoundingRect()
1817 bounds
.adjust(-64, 0, 0, 0)
1818 self
.setSceneRect(bounds
)
1819 self
.fit_view_to_items(items
)
1821 def zoom_to_fit(self
):
1822 """Fit selected items into the viewport"""
1823 items
= self
.selected_items()
1824 self
.fit_view_to_items(items
)
1826 def fit_view_to_items(self
, items
):
1828 rect
= self
.scene().itemsBoundingRect()
1830 x_min
= y_min
= maxsize
1831 x_max
= y_max
= -maxsize
1837 x_min
= min(x_min
, x_val
)
1838 x_max
= max(x_max
, x_val
)
1839 y_min
= min(y_min
, y_val
)
1840 y_max
= max(y_max
, y_val
)
1842 rect
= QtCore
.QRectF(x_min
, y_min
, abs(x_max
- x_min
), abs(y_max
- y_min
))
1844 x_adjust
= abs(GraphView
.x_adjust
)
1845 y_adjust
= abs(GraphView
.y_adjust
)
1847 count
= max(2.0, 10.0 - len(items
) / 2.0)
1848 y_offset
= int(y_adjust
* count
)
1849 x_offset
= int(x_adjust
* count
)
1850 rect
.setX(rect
.x() - x_offset
// 2)
1851 rect
.setY(rect
.y() - y_adjust
// 2)
1852 rect
.setHeight(rect
.height() + y_offset
)
1853 rect
.setWidth(rect
.width() + x_offset
)
1855 self
.fitInView(rect
, Qt
.KeepAspectRatio
)
1856 self
.scene().invalidate()
1858 def handle_event(self
, event_handler
, event
, update
=True):
1859 event_handler(self
, event
)
1863 def set_selecting(self
, selecting
):
1864 self
.selecting
= selecting
1866 def pan(self
, event
):
1868 x_offset
= pos
.x() - self
.mouse_start
[0]
1869 y_offset
= pos
.y() - self
.mouse_start
[1]
1871 if x_offset
== 0 and y_offset
== 0:
1874 rect
= QtCore
.QRect(0, 0, abs(x_offset
), abs(y_offset
))
1875 delta
= self
.mapToScene(rect
).boundingRect()
1877 x_translate
= delta
.width()
1879 x_translate
= -x_translate
1881 y_translate
= delta
.height()
1883 y_translate
= -y_translate
1885 matrix
= self
.transform()
1887 matrix
*= self
.saved_matrix
1888 matrix
.translate(x_translate
, y_translate
)
1890 self
.setTransformationAnchor(QtWidgets
.QGraphicsView
.NoAnchor
)
1891 self
.setTransform(matrix
)
1893 def wheel_zoom(self
, event
):
1894 """Handle mouse wheel zooming."""
1895 delta
= qtcompat
.wheel_delta(event
)
1896 zoom
= math
.pow(2.0, delta
/ 512.0)
1900 .mapRect(QtCore
.QRectF(0.0, 0.0, 1.0, 1.0))
1903 if factor
< 0.014 or factor
> 42.0:
1905 self
.setTransformationAnchor(QtWidgets
.QGraphicsView
.AnchorUnderMouse
)
1907 self
.scale(zoom
, zoom
)
1909 def wheel_pan(self
, event
):
1910 """Handle mouse wheel panning."""
1911 unit
= QtCore
.QRectF(0.0, 0.0, 1.0, 1.0)
1912 factor
= 1.0 / self
.transform().mapRect(unit
).width()
1913 tx
, ty
= qtcompat
.wheel_translation(event
)
1915 matrix
= self
.transform().translate(tx
* factor
, ty
* factor
)
1916 self
.setTransformationAnchor(QtWidgets
.QGraphicsView
.NoAnchor
)
1917 self
.setTransform(matrix
)
1919 def scale_view(self
, scale
):
1922 .scale(scale
, scale
)
1923 .mapRect(QtCore
.QRectF(0, 0, 1, 1))
1926 if factor
< 0.07 or factor
> 100.0:
1930 adjust_scrollbars
= False
1931 scrollbar
= self
.verticalScrollBar()
1932 scrollbar_offset
= 1.0
1934 value
= get(scrollbar
)
1935 minimum
= scrollbar
.minimum()
1936 maximum
= scrollbar
.maximum()
1937 scrollbar_range
= maximum
- minimum
1938 distance
= value
- minimum
1939 nonzero_range
= scrollbar_range
> 0.1
1941 scrollbar_offset
= distance
/ scrollbar_range
1942 adjust_scrollbars
= True
1944 self
.setTransformationAnchor(QtWidgets
.QGraphicsView
.NoAnchor
)
1945 self
.scale(scale
, scale
)
1947 scrollbar
= self
.verticalScrollBar()
1948 if scrollbar
and adjust_scrollbars
:
1949 minimum
= scrollbar
.minimum()
1950 maximum
= scrollbar
.maximum()
1951 scrollbar_range
= maximum
- minimum
1952 value
= minimum
+ int(float(scrollbar_range
) * scrollbar_offset
)
1953 scrollbar
.setValue(value
)
1955 def add_commits(self
, commits
):
1956 """Traverse commits and add them to the view."""
1957 self
.commits
.extend(commits
)
1958 scene
= self
.scene()
1959 for commit
in commits
:
1960 item
= Commit(commit
)
1961 self
.items
[commit
.oid
] = item
1962 for ref
in commit
.tags
:
1963 self
.items
[ref
] = item
1966 self
.layout_commits()
1969 def link(self
, commits
):
1970 """Create edges linking commits with their parents"""
1971 scene
= self
.scene()
1972 for commit
in commits
:
1974 commit_item
= self
.items
[commit
.oid
]
1976 continue # The history is truncated.
1977 for parent
in reversed(commit
.parents
):
1979 parent_item
= self
.items
[parent
.oid
]
1981 continue # The history is truncated.
1983 edge
= parent_item
.edges
[commit
.oid
]
1985 edge
= Edge(parent_item
, commit_item
)
1988 parent_item
.edges
[commit
.oid
] = edge
1989 commit_item
.edges
[parent
.oid
] = edge
1992 def layout_commits(self
):
1993 positions
= self
.position_nodes()
1995 # Each edge is accounted in two commits. Hence, accumulate invalid
1996 # edges to prevent double edge invalidation.
1997 invalid_edges
= set()
1999 for oid
, (x_val
, y_val
) in positions
.items():
2000 item
= self
.items
[oid
]
2003 if pos
!= (x_val
, y_val
):
2004 item
.setPos(x_val
, y_val
)
2006 for edge
in item
.edges
.values():
2007 invalid_edges
.add(edge
)
2009 for edge
in invalid_edges
:
2010 edge
.commits_were_invalidated()
2012 # Commit node layout technique
2014 # Nodes are aligned by a mesh. Columns and rows are distributed using
2015 # algorithms described below.
2017 # Row assignment algorithm
2019 # The algorithm aims consequent.
2020 # 1. A commit should be above all its parents.
2021 # 2. No commit should be at right side of a commit with a tag in same row.
2022 # This prevents overlapping of tag labels with commits and other labels.
2023 # 3. Commit density should be maximized.
2025 # The algorithm requires that all parents of a commit were assigned column.
2026 # Nodes must be traversed in generation ascend order. This guarantees that all
2027 # parents of a commit were assigned row. So, the algorithm may operate in
2028 # course of column assignment algorithm.
2030 # Row assignment uses frontier. A frontier is a dictionary that contains
2031 # minimum available row index for each column. It propagates during the
2032 # algorithm. Set of cells with tags is also maintained to meet second aim.
2034 # Initialization is performed by reset_rows method. Each new column should
2035 # be declared using declare_column method. Getting row for a cell is
2036 # implemented in alloc_cell method. Frontier must be propagated for any child
2037 # of fork commit which occupies different column. This meets first aim.
2039 # Column assignment algorithm
2041 # The algorithm traverses nodes in generation ascend order. This guarantees
2042 # that a node will be visited after all its parents.
2044 # The set of occupied columns are maintained during work. Initially it is
2045 # empty and no node occupied a column. Empty columns are allocated on demand.
2046 # Free index for column being allocated is searched in following way.
2047 # 1. Start from desired column and look towards graph center (0 column).
2048 # 2. Start from center and look in both directions simultaneously.
2049 # Desired column is defaulted to 0. Fork node should set desired column for
2050 # children equal to its one. This prevents branch from jumping too far from
2053 # Initialization is performed by reset_columns method. Column allocation is
2054 # implemented in alloc_column method. Initialization and main loop are in
2055 # recompute_grid method. The method also embeds row assignment algorithm by
2058 # Actions for each node are follow.
2059 # 1. If the node was not assigned a column then it is assigned empty one.
2061 # 3. Allocate columns for children.
2062 # If a child have a column assigned then it should no be overridden. One of
2063 # children is assigned same column as the node. If the node is a fork then the
2064 # child is chosen in generation descent order. This is a heuristic and it only
2065 # affects resulting appearance of the graph. Other children are assigned empty
2066 # columns in same order. It is the heuristic too.
2067 # 4. If no child occupies column of the node then leave it.
2068 # It is possible in consequent situations.
2069 # 4.1 The node is a leaf.
2070 # 4.2 The node is a fork and all its children are already assigned side
2071 # column. It is possible if all the children are merges.
2072 # 4.3 Single node child is a merge that is already assigned a column.
2073 # 5. Propagate frontier with respect to this node.
2074 # Each frontier entry corresponding to column occupied by any node's child
2075 # must be gather than node row index. This meets first aim of the row
2076 # assignment algorithm.
2077 # Note that frontier of child that occupies same row was propagated during
2078 # step 2. Hence, it must be propagated for children on side columns.
2080 def reset_columns(self
):
2081 # Some children of displayed commits might not be accounted in
2082 # 'commits' list. It is common case during loading of big graph.
2083 # But, they are assigned a column that must be reseted. Hence, use
2084 # depth-first traversal to reset all columns assigned.
2085 for node
in self
.commits
:
2086 if node
.column
is None:
2092 for child
in node
.children
:
2093 if child
.column
is not None:
2100 def reset_rows(self
):
2102 self
.tagged_cells
= set()
2104 def declare_column(self
, column
):
2106 # Align new column frontier by frontier of nearest column. If all
2107 # columns were left then select maximum frontier value.
2108 if not self
.columns
:
2109 self
.frontier
[column
] = max(list(self
.frontier
.values()))
2111 # This is heuristic that mostly affects roots. Note that the
2112 # frontier values for fork children will be overridden in course of
2113 # propagate_frontier.
2114 for offset
in itertools
.count(1):
2115 for value
in (column
+ offset
, column
- offset
):
2116 if value
not in self
.columns
:
2117 # Column is not occupied.
2120 frontier
= self
.frontier
[value
]
2122 # Column 'c' was never allocated.
2126 # The frontier of the column may be higher because of
2127 # tag overlapping prevention performed for previous head.
2129 if self
.frontier
[column
] >= frontier
:
2134 self
.frontier
[column
] = frontier
2140 # First commit must be assigned 0 row.
2141 self
.frontier
[column
] = 0
2143 def alloc_column(self
, column
=0):
2144 columns
= self
.columns
2145 # First, look for free column by moving from desired column to graph
2146 # center (column 0).
2147 for c
in range(column
, 0, -1 if column
> 0 else 1):
2148 if c
not in columns
:
2149 if c
> self
.max_column
:
2151 elif c
< self
.min_column
:
2155 # If no free column was found between graph center and desired
2156 # column then look for free one by moving from center along both
2157 # directions simultaneously.
2158 for c
in itertools
.count(0):
2159 if c
not in columns
:
2160 if c
> self
.max_column
:
2164 if c
not in columns
:
2165 if c
< self
.min_column
:
2168 self
.declare_column(c
)
2172 def alloc_cell(self
, column
, tags
):
2173 # Get empty cell from frontier.
2174 cell_row
= self
.frontier
[column
]
2177 # Prevent overlapping of tag with cells already allocated a row.
2179 can_overlap
= list(range(column
+ 1, self
.max_column
+ 1))
2181 can_overlap
= list(range(column
- 1, self
.min_column
- 1, -1))
2182 for value
in can_overlap
:
2183 frontier
= self
.frontier
[value
]
2184 if frontier
> cell_row
:
2187 # Avoid overlapping with tags of commits at cell_row.
2189 can_overlap
= list(range(self
.min_column
, column
))
2191 can_overlap
= list(range(self
.max_column
, column
, -1))
2192 for cell_row
in itertools
.count(cell_row
):
2193 for value
in can_overlap
:
2194 if (value
, cell_row
) in self
.tagged_cells
:
2195 # Overlapping. Try next row.
2198 # No overlapping was found.
2200 # Note that all checks should be made for new cell_row value.
2203 self
.tagged_cells
.add((column
, cell_row
))
2205 # Propagate frontier.
2206 self
.frontier
[column
] = cell_row
+ 1
2209 def propagate_frontier(self
, column
, value
):
2210 current
= self
.frontier
[column
]
2212 self
.frontier
[column
] = value
2214 def leave_column(self
, column
):
2215 count
= self
.columns
[column
]
2217 del self
.columns
[column
]
2219 self
.columns
[column
] = count
- 1
2221 def recompute_grid(self
):
2222 self
.reset_columns()
2225 for node
in sort_by_generation(list(self
.commits
)):
2226 if node
.column
is None:
2227 # Node is either root or its parent is not in items. This
2228 # happens when tree loading is in progress. Allocate new
2229 # columns for such nodes.
2230 node
.column
= self
.alloc_column()
2232 node
.row
= self
.alloc_cell(node
.column
, node
.tags
)
2234 # Allocate columns for children which are still without one. Also
2235 # propagate frontier for children.
2237 sorted_children
= sorted(
2238 node
.children
, key
=lambda c
: c
.generation
, reverse
=True
2240 citer
= iter(sorted_children
)
2242 if child
.column
is None:
2243 # Top most child occupies column of parent.
2244 child
.column
= node
.column
2245 # Note that frontier is propagated in course of
2248 self
.propagate_frontier(child
.column
, node
.row
+ 1)
2250 # No child occupies same column.
2251 self
.leave_column(node
.column
)
2252 # Note that the loop below will pass no iteration.
2254 # Rest children are allocated new column.
2256 if child
.column
is None:
2257 child
.column
= self
.alloc_column(node
.column
)
2258 self
.propagate_frontier(child
.column
, node
.row
+ 1)
2260 child
= node
.children
[0]
2261 if child
.column
is None:
2262 child
.column
= node
.column
2263 # Note that frontier is propagated in course of alloc_cell.
2264 elif child
.column
!= node
.column
:
2265 # Child node have other parents and occupies column of one
2267 self
.leave_column(node
.column
)
2268 # But frontier must be propagated with respect to this
2270 self
.propagate_frontier(child
.column
, node
.row
+ 1)
2272 # This is a leaf node.
2273 self
.leave_column(node
.column
)
2275 def position_nodes(self
):
2276 self
.recompute_grid()
2278 x_start
= self
.x_start
2285 for node
in self
.commits
:
2286 x_val
= x_start
+ node
.column
* x_off
2287 y_val
= y_off
+ node
.row
* y_off
2289 positions
[node
.oid
] = (x_val
, y_val
)
2290 x_min
= min(x_min
, x_val
)
2297 def contextMenuEvent(self
, event
):
2298 self
.context_menu_event(event
)
2300 def mousePressEvent(self
, event
):
2301 if event
.button() == Qt
.MidButton
:
2303 self
.mouse_start
= [pos
.x(), pos
.y()]
2304 self
.saved_matrix
= self
.transform()
2305 self
.is_panning
= True
2307 if event
.button() == Qt
.RightButton
:
2310 if event
.button() == Qt
.LeftButton
:
2312 self
.handle_event(QtWidgets
.QGraphicsView
.mousePressEvent
, event
)
2314 def mouseMoveEvent(self
, event
):
2318 pos
= self
.mapToScene(event
.pos())
2319 self
.last_mouse
[0] = pos
.x()
2320 self
.last_mouse
[1] = pos
.y()
2321 self
.handle_event(QtWidgets
.QGraphicsView
.mouseMoveEvent
, event
, update
=False)
2323 def mouseReleaseEvent(self
, event
):
2324 self
.pressed
= False
2325 if event
.button() == Qt
.MidButton
:
2326 self
.is_panning
= False
2328 self
.handle_event(QtWidgets
.QGraphicsView
.mouseReleaseEvent
, event
)
2329 self
.viewport().repaint()
2331 def wheelEvent(self
, event
):
2332 """Handle Qt mouse wheel events."""
2333 if event
.modifiers() & Qt
.ControlModifier
:
2334 self
.wheel_zoom(event
)
2336 self
.wheel_pan(event
)
2338 def fitInView(self
, rect
, flags
=Qt
.IgnoreAspectRatio
):
2339 """Override fitInView to remove unwanted margins
2341 https://bugreports.qt.io/browse/QTBUG-42331 - based on QT sources
2344 if self
.scene() is None or rect
.isNull():
2346 unity
= self
.transform().mapRect(QtCore
.QRectF(0, 0, 1, 1))
2347 self
.scale(1.0 / unity
.width(), 1.0 / unity
.height())
2348 view_rect
= self
.viewport().rect()
2349 scene_rect
= self
.transform().mapRect(rect
)
2350 xratio
= view_rect
.width() / scene_rect
.width()
2351 yratio
= view_rect
.height() / scene_rect
.height()
2352 if flags
== Qt
.KeepAspectRatio
:
2353 xratio
= yratio
= min(xratio
, yratio
)
2354 elif flags
== Qt
.KeepAspectRatioByExpanding
:
2355 xratio
= yratio
= max(xratio
, yratio
)
2356 self
.scale(xratio
, yratio
)
2357 self
.centerOn(rect
.center())
2360 def sort_by_generation(commits
):
2361 """Sort commits by their generation. Ensures consistent diffs and patch exports"""
2362 if len(commits
) <= 1:
2364 commits
.sort(key
=lambda x
: x
.generation
)
2370 # oid -- Git objects IDs (i.e. SHA-1 IDs)
2371 # ref -- Git references that resolve to a commit-ish (HEAD, branches, tags)