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 commit 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 common 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 class CommitTreeWidget(standard
.TreeWidget
, ViewerMixin
):
560 """Display commits using a flat treewidget in "list" mode"""
562 commits_selected
= Signal(object)
563 diff_commits
= Signal(object, object)
564 zoom_to_fit
= Signal()
566 def __init__(self
, context
, parent
):
567 standard
.TreeWidget
.__init
__(self
, parent
)
568 ViewerMixin
.__init
__(self
)
570 self
.setSelectionMode(self
.ExtendedSelection
)
571 self
.setHeaderLabels([N_('Summary'), N_('Author'), N_('Date, Time')])
573 self
.context
= context
575 self
.menu_actions
= None
576 self
.selecting
= False
578 self
._adjust
_columns
= False
580 self
.action_up
= qtutils
.add_action(
581 self
, N_('Go Up'), self
.go_up
, hotkeys
.MOVE_UP
584 self
.action_down
= qtutils
.add_action(
585 self
, N_('Go Down'), self
.go_down
, hotkeys
.MOVE_DOWN
588 self
.zoom_to_fit_action
= qtutils
.add_action(
589 self
, N_('Zoom to Fit'), self
.zoom_to_fit
.emit
, hotkeys
.FIT
592 self
.itemSelectionChanged
.connect(self
.selection_changed
)
594 def export_state(self
):
595 """Export the widget's state"""
596 # The base class method is intentionally overridden because we only
597 # care about the details below for this sub-widget.
599 state
['column_widths'] = self
.column_widths()
602 def apply_state(self
, state
):
603 """Apply the exported widget state"""
605 column_widths
= state
['column_widths']
606 except (KeyError, ValueError):
609 self
.set_column_widths(column_widths
)
611 # Defer showing the columns until we are shown, and our true width
612 # is known. Calling adjust_columns() here ends up with the wrong
613 # answer because we have not yet been parented to the layout.
614 # We set this flag that we process once during our initial
616 self
._adjust
_columns
= True
620 def showEvent(self
, event
):
621 """Override QWidget::showEvent() to size columns when we are shown"""
622 if self
._adjust
_columns
:
623 self
._adjust
_columns
= False
625 two_thirds
= (width
* 2) // 3
626 one_sixth
= width
// 6
628 self
.setColumnWidth(0, two_thirds
)
629 self
.setColumnWidth(1, one_sixth
)
630 self
.setColumnWidth(2, one_sixth
)
631 return standard
.TreeWidget
.showEvent(self
, event
)
635 """Select the item above the current item"""
636 self
.goto(self
.itemAbove
)
639 """Select the item below the current item"""
640 self
.goto(self
.itemBelow
)
642 def goto(self
, finder
):
643 """Move the selection using a finder strategy"""
644 items
= self
.selected_items()
645 item
= items
[0] if items
else None
650 self
.select([found
.commit
.oid
])
652 def selected_commit_range(self
):
653 """Return a range of selected commits"""
654 selected_items
= self
.selected_items()
655 if not selected_items
:
657 return selected_items
[-1].commit
.oid
, selected_items
[0].commit
.oid
659 def set_selecting(self
, selecting
):
660 """Record the "are we selecting?" status"""
661 self
.selecting
= selecting
663 def selection_changed(self
):
664 """Respond to itemSelectionChanged notifications"""
665 items
= self
.selected_items()
667 self
.set_selecting(True)
668 self
.commits_selected
.emit([])
669 self
.set_selecting(False)
671 self
.set_selecting(True)
672 self
.commits_selected
.emit(sort_by_generation([i
.commit
for i
in items
]))
673 self
.set_selecting(False)
675 def select_commits(self
, commits
):
676 """Select commits that were selected by the sibling tree/graph widget"""
679 with qtutils
.BlockSignals(self
):
680 self
.select([commit
.oid
for commit
in commits
])
682 def select(self
, oids
):
683 """Mark items as selected"""
684 self
.clearSelection()
689 item
= self
.oidmap
[oid
]
692 self
.scrollToItem(item
)
693 item
.setSelected(True)
697 QtWidgets
.QTreeWidget
.clear(self
)
701 def add_commits(self
, commits
):
702 """Add commits to the tree"""
703 self
.commits
.extend(commits
)
705 for c
in reversed(commits
):
706 item
= CommitTreeWidgetItem(c
)
708 self
.oidmap
[c
.oid
] = item
710 self
.oidmap
[tag
] = item
711 self
.insertTopLevelItems(0, items
)
713 def create_patch(self
):
714 """Export a patch from the selected items"""
715 items
= self
.selectedItems()
718 context
= self
.context
719 oids
= [item
.commit
.oid
for item
in reversed(items
)]
720 all_oids
= [c
.oid
for c
in self
.commits
]
721 cmds
.do(cmds
.FormatPatch
, context
, oids
, all_oids
)
724 def contextMenuEvent(self
, event
):
725 """Create a custom context menu and execute it"""
726 self
.context_menu_event(event
)
728 def mousePressEvent(self
, event
):
729 """Intercept the right-click event to retain selection state"""
730 item
= self
.itemAt(event
.pos())
734 self
.clicked
= item
.commit
735 if event
.button() == Qt
.RightButton
:
738 QtWidgets
.QTreeWidget
.mousePressEvent(self
, event
)
741 class GitDAG(standard
.MainWindow
):
742 """The git-dag widget."""
744 commits_selected
= Signal(object)
746 def __init__(self
, context
, params
, parent
=None):
747 super().__init
__(parent
)
749 self
.setMinimumSize(420, 420)
751 # change when widgets are added/removed
752 self
.widget_version
= 2
753 self
.context
= context
755 self
.model
= context
.model
758 self
.commit_list
= []
760 self
.old_refs
= set()
763 self
.force_refresh
= False
766 self
.revtext
= GitDagLineEdit(context
)
767 self
.maxresults
= standard
.SpinBox()
769 self
.zoom_out
= qtutils
.create_action_button(
770 tooltip
=N_('Zoom Out'), icon
=icons
.zoom_out()
773 self
.zoom_in
= qtutils
.create_action_button(
774 tooltip
=N_('Zoom In'), icon
=icons
.zoom_in()
777 self
.zoom_to_fit
= qtutils
.create_action_button(
778 tooltip
=N_('Zoom to Fit'), icon
=icons
.zoom_fit_best()
781 self
.treewidget
= CommitTreeWidget(context
, self
)
782 self
.diffwidget
= diff
.DiffWidget(context
, self
, is_commit
=True)
783 self
.filewidget
= filelist
.FileWidget(context
, self
)
784 self
.graphview
= GraphView(context
, self
)
786 self
.treewidget
.commits_selected
.connect(self
.commits_selected
)
787 self
.graphview
.commits_selected
.connect(self
.commits_selected
)
789 self
.commits_selected
.connect(self
.select_commits
)
790 self
.commits_selected
.connect(self
.diffwidget
.commits_selected
)
791 self
.commits_selected
.connect(self
.filewidget
.commits_selected
)
792 self
.commits_selected
.connect(self
.graphview
.select_commits
)
793 self
.commits_selected
.connect(self
.treewidget
.select_commits
)
795 self
.filewidget
.files_selected
.connect(self
.diffwidget
.files_selected
)
796 self
.filewidget
.difftool_selected
.connect(self
.difftool_selected
)
797 self
.filewidget
.histories_selected
.connect(self
.histories_selected
)
799 self
.proxy
= FocusRedirectProxy(
800 self
.treewidget
, self
.graphview
, self
.filewidget
803 self
.viewer_actions
= actions
= viewer_actions(self
)
804 self
.treewidget
.menu_actions
= actions
805 self
.graphview
.menu_actions
= actions
807 self
.controls_layout
= qtutils
.hbox(
808 defs
.no_margin
, defs
.spacing
, self
.revtext
, self
.maxresults
811 self
.controls_widget
= QtWidgets
.QWidget()
812 self
.controls_widget
.setLayout(self
.controls_layout
)
814 self
.log_dock
= qtutils
.create_dock('Log', N_('Log'), self
, stretch
=False)
815 self
.log_dock
.setWidget(self
.treewidget
)
816 log_dock_titlebar
= self
.log_dock
.titleBarWidget()
817 log_dock_titlebar
.add_corner_widget(self
.controls_widget
)
819 self
.file_dock
= qtutils
.create_dock('Files', N_('Files'), self
)
820 self
.file_dock
.setWidget(self
.filewidget
)
822 self
.diff_panel
= diff
.DiffPanel(self
.diffwidget
, self
.diffwidget
.diff
, self
)
823 self
.diff_options
= diff
.Options(self
.diffwidget
)
824 self
.diffwidget
.set_options(self
.diff_options
)
825 self
.diff_options
.hide_advanced_options()
826 self
.diff_options
.set_diff_type(main
.Types
.TEXT
)
828 self
.diff_dock
= qtutils
.create_dock('Diff', N_('Diff'), self
)
829 self
.diff_dock
.setWidget(self
.diff_panel
)
831 diff_titlebar
= self
.diff_dock
.titleBarWidget()
832 diff_titlebar
.add_corner_widget(self
.diff_options
)
834 self
.graph_controls_layout
= qtutils
.hbox(
843 self
.graph_controls_widget
= QtWidgets
.QWidget()
844 self
.graph_controls_widget
.setLayout(self
.graph_controls_layout
)
846 self
.graphview_dock
= qtutils
.create_dock('Graph', N_('Graph'), self
)
847 self
.graphview_dock
.setWidget(self
.graphview
)
848 graph_titlebar
= self
.graphview_dock
.titleBarWidget()
849 graph_titlebar
.add_corner_widget(self
.graph_controls_widget
)
851 self
.lock_layout_action
= qtutils
.add_action_bool(
852 self
, N_('Lock Layout'), self
.set_lock_layout
, False
855 self
.refresh_action
= qtutils
.add_action(
856 self
, N_('Refresh'), self
.refresh
, hotkeys
.REFRESH
859 # Create the application menu
860 self
.menubar
= QtWidgets
.QMenuBar(self
)
861 self
.setMenuBar(self
.menubar
)
864 self
.view_menu
= qtutils
.add_menu(N_('View'), self
.menubar
)
865 self
.view_menu
.addAction(self
.refresh_action
)
866 self
.view_menu
.addAction(self
.log_dock
.toggleViewAction())
867 self
.view_menu
.addAction(self
.graphview_dock
.toggleViewAction())
868 self
.view_menu
.addAction(self
.diff_dock
.toggleViewAction())
869 self
.view_menu
.addAction(self
.file_dock
.toggleViewAction())
870 self
.view_menu
.addSeparator()
871 self
.view_menu
.addAction(self
.lock_layout_action
)
873 left
= Qt
.LeftDockWidgetArea
874 right
= Qt
.RightDockWidgetArea
875 self
.addDockWidget(left
, self
.log_dock
)
876 self
.addDockWidget(left
, self
.diff_dock
)
877 self
.addDockWidget(right
, self
.graphview_dock
)
878 self
.addDockWidget(right
, self
.file_dock
)
880 # Also re-loads dag.* from the saved state
881 self
.init_state(context
.settings
, self
.resize_to_desktop
)
883 qtutils
.connect_button(self
.zoom_out
, self
.graphview
.zoom_out
)
884 qtutils
.connect_button(self
.zoom_in
, self
.graphview
.zoom_in
)
885 qtutils
.connect_button(self
.zoom_to_fit
, self
.graphview
.zoom_to_fit
)
887 self
.treewidget
.zoom_to_fit
.connect(self
.graphview
.zoom_to_fit
)
888 self
.treewidget
.diff_commits
.connect(self
.diff_commits
)
889 self
.graphview
.diff_commits
.connect(self
.diff_commits
)
890 self
.filewidget
.grab_file
.connect(self
.grab_file
)
891 self
.maxresults
.editingFinished
.connect(self
.display
)
892 self
.revtext
.textChanged
.connect(self
.text_changed
)
893 self
.revtext
.activated
.connect(self
.display
)
894 self
.revtext
.enter
.connect(self
.display
)
895 self
.revtext
.down
.connect(self
.focus_tree
)
896 # The model is updated in another thread so use
897 # signals/slots to bring control back to the main GUI thread
898 self
.model
.updated
.connect(self
.model_updated
, type=Qt
.QueuedConnection
)
900 qtutils
.add_action(self
, 'FocusInput', self
.focus_input
, hotkeys
.FOCUS_INPUT
)
901 qtutils
.add_action(self
, 'FocusTree', self
.focus_tree
, hotkeys
.FOCUS_TREE
)
902 qtutils
.add_action(self
, 'FocusDiff', self
.focus_diff
, hotkeys
.FOCUS_DIFF
)
903 qtutils
.add_close_action(self
)
905 self
.set_params(params
)
907 def set_params(self
, params
):
908 context
= self
.context
911 # Update fields affected by model
912 self
.revtext
.setText(params
.ref
)
913 self
.maxresults
.setValue(params
.count
)
914 self
.update_window_title()
916 if self
.thread
is not None:
919 self
.thread
= ReaderThread(context
, params
, self
)
922 thread
.begin
.connect(self
.thread_begin
, type=Qt
.QueuedConnection
)
923 thread
.status
.connect(self
.thread_status
, type=Qt
.QueuedConnection
)
924 thread
.add
.connect(self
.add_commits
, type=Qt
.QueuedConnection
)
925 thread
.end
.connect(self
.thread_end
, type=Qt
.QueuedConnection
)
927 def focus_input(self
):
928 """Focus the revision input field"""
929 self
.revtext
.setFocus()
931 def focus_tree(self
):
932 """Focus the revision tree list widget"""
933 self
.treewidget
.setFocus()
935 def focus_diff(self
):
936 """Focus the diff widget"""
937 self
.diffwidget
.setFocus()
939 def text_changed(self
, txt
):
940 self
.params
.ref
= txt
941 self
.update_window_title()
943 def update_window_title(self
):
944 project
= self
.model
.project
947 N_('%(project)s: %(ref)s - DAG')
950 'ref': self
.params
.ref
,
954 self
.setWindowTitle(project
+ N_(' - DAG'))
956 def export_state(self
):
957 state
= standard
.MainWindow
.export_state(self
)
958 state
['count'] = self
.params
.count
959 state
['log'] = self
.treewidget
.export_state()
960 state
['word_wrap'] = self
.diffwidget
.options
.enable_word_wrapping
.isChecked()
963 def apply_state(self
, state
):
964 result
= standard
.MainWindow
.apply_state(self
, state
)
966 count
= state
['count']
967 if self
.params
.overridden('count'):
968 count
= self
.params
.count
969 except (KeyError, TypeError, ValueError, AttributeError):
970 count
= self
.params
.count
972 self
.params
.set_count(count
)
973 self
.lock_layout_action
.setChecked(state
.get('lock_layout', False))
974 self
.diffwidget
.set_word_wrapping(state
.get('word_wrap', False), update
=True)
977 log_state
= state
['log']
978 except (KeyError, ValueError):
981 self
.treewidget
.apply_state(log_state
)
985 def model_updated(self
):
987 self
.update_window_title()
990 """Unconditionally refresh the DAG"""
991 # self.force_refresh triggers an Unconditional redraw
992 self
.force_refresh
= True
993 cmds
.do(cmds
.Refresh
, self
.context
)
996 """Update the view when the Git refs change"""
997 ref
= get(self
.revtext
)
998 count
= get(self
.maxresults
)
999 context
= self
.context
1001 # The DAG tries to avoid updating when the object IDs have not
1002 # changed. Without doing this the DAG constantly redraws itself
1003 # whenever inotify sends update events, which hurts usability.
1005 # To minimize redraws we leverage `git rev-parse`. The strategy is to
1006 # use `git rev-parse` on the input line, which converts each argument
1007 # into object IDs. From there it's a simple matter of detecting when
1008 # the object IDs changed.
1010 # In addition to object IDs, we also need to know when the set of
1011 # named references (branches, tags) changes so that an update is
1012 # triggered when new branches and tags are created.
1013 refs
= set(model
.local_branches
+ model
.remote_branches
+ model
.tags
)
1014 argv
= utils
.shell_split(ref
or 'HEAD')
1015 oids
= gitcmds
.parse_refs(context
, argv
)
1018 or count
!= self
.old_count
1019 or oids
!= self
.old_oids
1020 or refs
!= self
.old_refs
1024 self
.params
.set_ref(ref
)
1025 self
.params
.set_count(count
)
1028 self
.old_oids
= oids
1029 self
.old_count
= count
1030 self
.old_refs
= refs
1031 self
.force_refresh
= False
1033 def select_commits(self
, commits
):
1034 self
.selection
= commits
1037 self
.commits
.clear()
1038 self
.commit_list
= []
1039 self
.graphview
.clear()
1040 self
.treewidget
.clear()
1042 def add_commits(self
, commits
):
1043 self
.commit_list
.extend(commits
)
1044 # Keep track of commits
1045 for commit_obj
in commits
:
1046 self
.commits
[commit_obj
.oid
] = commit_obj
1047 for tag
in commit_obj
.tags
:
1048 self
.commits
[tag
] = commit_obj
1049 self
.graphview
.add_commits(commits
)
1050 self
.treewidget
.add_commits(commits
)
1052 def thread_begin(self
):
1055 def thread_end(self
):
1056 self
.restore_selection()
1058 def thread_status(self
, successful
):
1059 self
.revtext
.hint
.set_error(not successful
)
1061 def restore_selection(self
):
1062 selection
= self
.selection
1064 commit_obj
= self
.commit_list
[-1]
1066 # No commits, exist, early-out
1069 new_commits
= [self
.commits
.get(s
.oid
, None) for s
in selection
]
1070 new_commits
= [c
for c
in new_commits
if c
is not None]
1072 # The old selection exists in the new state
1073 self
.commits_selected
.emit(sort_by_generation(new_commits
))
1075 # The old selection is now empty. Select the top-most commit
1076 self
.commits_selected
.emit([commit_obj
])
1078 self
.graphview
.set_initial_view()
1080 def diff_commits(self
, left
, right
):
1081 paths
= self
.params
.paths()
1083 difftool
.difftool_launch(self
.context
, left
=left
, right
=right
, paths
=paths
)
1085 difftool
.diff_commits(self
.context
, self
, left
, right
)
1088 def closeEvent(self
, event
):
1089 self
.revtext
.close_popup()
1091 standard
.MainWindow
.closeEvent(self
, event
)
1093 def histories_selected(self
, histories
):
1094 argv
= [self
.model
.currentbranch
, '--']
1095 argv
.extend(histories
)
1096 rev_text
= core
.list2cmdline(argv
)
1097 self
.revtext
.setText(rev_text
)
1100 def difftool_selected(self
, files
):
1101 bottom
, top
= self
.treewidget
.selected_commit_range()
1104 difftool
.difftool_launch(
1105 self
.context
, left
=bottom
, left_take_parent
=True, right
=top
, paths
=files
1108 def grab_file(self
, filename
):
1109 """Save the selected file from the file list widget"""
1110 oid
= self
.treewidget
.selected_oid()
1111 model
= browse
.BrowseModel(oid
, filename
=filename
)
1112 browse
.save_path(self
.context
, filename
, model
)
1115 class ReaderThread(QtCore
.QThread
):
1117 add
= Signal(object)
1119 status
= Signal(object)
1121 def __init__(self
, context
, params
, parent
):
1122 QtCore
.QThread
.__init
__(self
, parent
)
1123 self
.context
= context
1124 self
.params
= params
1127 self
._mutex
= QtCore
.QMutex()
1128 self
._condition
= QtCore
.QWaitCondition()
1131 context
= self
.context
1132 repo
= dag
.RepoReader(context
, self
.params
)
1136 for commit
in repo
.get():
1139 self
._condition
.wait(self
._mutex
)
1140 self
._mutex
.unlock()
1144 commits
.append(commit
)
1145 if len(commits
) >= 512:
1146 self
.add
.emit(commits
)
1149 self
.status
.emit(repo
.returncode
== 0)
1151 self
.add
.emit(commits
)
1157 QtCore
.QThread
.start(self
)
1162 self
._mutex
.unlock()
1167 self
._mutex
.unlock()
1168 self
._condition
.wakeOne()
1179 def label_font(cls
):
1180 font
= cls
._label
_font
1182 font
= cls
._label
_font
= QtWidgets
.QApplication
.font()
1183 font
.setPointSize(6)
1187 class Edge(QtWidgets
.QGraphicsItem
):
1188 item_type
= qtutils
.standard_item_type_value(1)
1190 def __init__(self
, source
, dest
):
1191 QtWidgets
.QGraphicsItem
.__init
__(self
)
1193 self
.setAcceptedMouseButtons(Qt
.NoButton
)
1194 self
.source
= source
1196 self
.commit
= source
.commit
1199 self
.recompute_bound()
1201 self
.path_valid
= False
1203 # Choose a new color for new branch edges
1204 if self
.source
.x() < self
.dest
.x():
1205 color
= EdgeColor
.cycle()
1207 elif self
.source
.x() != self
.dest
.x():
1208 color
= EdgeColor
.current()
1211 color
= EdgeColor
.current()
1214 self
.pen
= QtGui
.QPen(color
, 2.0, line
, Qt
.SquareCap
, Qt
.RoundJoin
)
1216 def recompute_bound(self
):
1217 dest_pt
= Commit
.item_bbox
.center()
1219 self
.source_pt
= self
.mapFromItem(self
.source
, dest_pt
)
1220 self
.dest_pt
= self
.mapFromItem(self
.dest
, dest_pt
)
1221 self
.line
= QtCore
.QLineF(self
.source_pt
, self
.dest_pt
)
1223 width
= self
.dest_pt
.x() - self
.source_pt
.x()
1224 height
= self
.dest_pt
.y() - self
.source_pt
.y()
1225 rect
= QtCore
.QRectF(self
.source_pt
, QtCore
.QSizeF(width
, height
))
1226 self
.bound
= rect
.normalized()
1228 def commits_were_invalidated(self
):
1229 self
.recompute_bound()
1230 self
.prepareGeometryChange()
1231 # The path should not be recomputed immediately because just small part
1232 # of DAG is actually shown at same time. It will be recomputed on
1233 # demand in course of 'paint' method.
1234 self
.path_valid
= False
1235 # Hence, just queue redrawing.
1240 return self
.item_type
1242 def boundingRect(self
):
1245 def recompute_path(self
):
1246 QRectF
= QtCore
.QRectF
1247 QPointF
= QtCore
.QPointF
1250 connector_length
= 5
1252 path
= QtGui
.QPainterPath()
1254 if self
.source
.x() == self
.dest
.x():
1255 path
.moveTo(self
.source
.x(), self
.source
.y())
1256 path
.lineTo(self
.dest
.x(), self
.dest
.y())
1258 # Define points starting from the source.
1259 point1
= QPointF(self
.source
.x(), self
.source
.y())
1260 point2
= QPointF(point1
.x(), point1
.y() - connector_length
)
1261 point3
= QPointF(point2
.x() + arc_rect
, point2
.y() - arc_rect
)
1263 # Define points starting from the destination.
1264 point4
= QPointF(self
.dest
.x(), self
.dest
.y())
1265 point5
= QPointF(point4
.x(), point3
.y() - arc_rect
)
1266 point6
= QPointF(point5
.x() - arc_rect
, point5
.y() + arc_rect
)
1268 start_angle_arc1
= 180
1269 span_angle_arc1
= 90
1270 start_angle_arc2
= 90
1271 span_angle_arc2
= -90
1273 # If the destination is at the left of the source, then we need to
1274 # reverse some values.
1275 if self
.source
.x() > self
.dest
.x():
1276 point3
= QPointF(point2
.x() - arc_rect
, point3
.y())
1277 point6
= QPointF(point5
.x() + arc_rect
, point6
.y())
1279 span_angle_arc1
= 90
1283 path
.arcTo(QRectF(point2
, point3
), start_angle_arc1
, span_angle_arc1
)
1285 path
.arcTo(QRectF(point6
, point5
), start_angle_arc2
, span_angle_arc2
)
1289 self
.path_valid
= True
1291 def paint(self
, painter
, _option
, _widget
):
1292 if not self
.path_valid
:
1293 self
.recompute_path()
1294 painter
.setPen(self
.pen
)
1295 painter
.drawPath(self
.path
)
1299 """An edge color factory"""
1301 current_color_index
= 0
1303 QtGui
.QColor(Qt
.red
),
1304 QtGui
.QColor(Qt
.cyan
),
1305 QtGui
.QColor(Qt
.magenta
),
1306 QtGui
.QColor(Qt
.green
),
1307 # Orange; Qt.yellow is too low-contrast
1308 qtutils
.rgba(0xFF, 0x66, 0x00),
1312 def update_colors(cls
, theme
):
1313 """Update the colors based on the color theme"""
1314 if theme
.is_dark
or theme
.is_palette_dark
:
1316 QtGui
.QColor(Qt
.red
).lighter(),
1317 QtGui
.QColor(Qt
.cyan
).lighter(),
1318 QtGui
.QColor(Qt
.magenta
).lighter(),
1319 QtGui
.QColor(Qt
.green
).lighter(),
1320 QtGui
.QColor(Qt
.yellow
).lighter(),
1324 QtGui
.QColor(Qt
.blue
),
1325 QtGui
.QColor(Qt
.darkRed
),
1326 QtGui
.QColor(Qt
.darkCyan
),
1327 QtGui
.QColor(Qt
.darkMagenta
),
1328 QtGui
.QColor(Qt
.darkGreen
),
1329 QtGui
.QColor(Qt
.darkYellow
),
1330 QtGui
.QColor(Qt
.darkBlue
),
1335 cls
.current_color_index
+= 1
1336 cls
.current_color_index
%= len(cls
.colors
)
1337 color
= cls
.colors
[cls
.current_color_index
]
1343 return cls
.colors
[cls
.current_color_index
]
1347 cls
.current_color_index
= 0
1350 class Commit(QtWidgets
.QGraphicsItem
):
1351 item_type
= qtutils
.standard_item_type_value(2)
1352 commit_radius
= 12.0
1355 item_shape
= QtGui
.QPainterPath()
1357 commit_radius
/ -2.0, commit_radius
/ -2.0, commit_radius
, commit_radius
1359 item_bbox
= item_shape
.boundingRect()
1361 inner_rect
= QtGui
.QPainterPath()
1363 commit_radius
/ -2.0 + 2.0,
1364 commit_radius
/ -2.0 + 2.0,
1365 commit_radius
- 4.0,
1366 commit_radius
- 4.0,
1368 inner_rect
= inner_rect
.boundingRect()
1370 commit_color
= QtGui
.QColor(Qt
.white
)
1371 outline_color
= commit_color
.darker()
1372 merge_color
= QtGui
.QColor(Qt
.lightGray
)
1374 commit_selected_color
= QtGui
.QColor(Qt
.green
)
1375 selected_outline_color
= commit_selected_color
.darker()
1377 commit_pen
= QtGui
.QPen()
1378 commit_pen
.setWidth(1)
1379 commit_pen
.setColor(outline_color
)
1384 selectable
=QtWidgets
.QGraphicsItem
.ItemIsSelectable
,
1385 cursor
=Qt
.PointingHandCursor
,
1386 xpos
=commit_radius
/ 2.0 + 1.0,
1387 cached_commit_color
=commit_color
,
1388 cached_merge_color
=merge_color
,
1390 QtWidgets
.QGraphicsItem
.__init
__(self
)
1392 self
.commit
= commit
1393 self
.selected
= False
1396 self
.setFlag(selectable
)
1397 self
.setCursor(cursor
)
1398 self
.setToolTip(commit
.oid
[:12] + ': ' + commit
.summary
)
1401 self
.label
= label
= Label(commit
)
1402 label
.setParentItem(self
)
1403 label
.setPos(xpos
+ 1, -self
.commit_radius
/ 2.0)
1407 if len(commit
.parents
) > 1:
1408 self
.brush
= cached_merge_color
1410 self
.brush
= cached_commit_color
1412 self
.pressed
= False
1413 self
.dragged
= False
1416 def itemChange(self
, change
, value
):
1417 if change
== QtWidgets
.QGraphicsItem
.ItemSelectedHasChanged
:
1418 # Cache the pen for use in paint()
1420 self
.brush
= self
.commit_selected_color
1421 color
= self
.selected_outline_color
1423 if len(self
.commit
.parents
) > 1:
1424 self
.brush
= self
.merge_color
1426 self
.brush
= self
.commit_color
1427 color
= self
.outline_color
1428 commit_pen
= QtGui
.QPen()
1429 commit_pen
.setWidth(1)
1430 commit_pen
.setColor(color
)
1431 self
.commit_pen
= commit_pen
1433 return QtWidgets
.QGraphicsItem
.itemChange(self
, change
, value
)
1436 return self
.item_type
1438 def boundingRect(self
):
1439 return self
.item_bbox
1442 return self
.item_shape
1444 def paint(self
, painter
, option
, _widget
):
1445 # Do not draw outside the exposed rectangle.
1446 painter
.setClipRect(option
.exposedRect
)
1449 painter
.setPen(self
.commit_pen
)
1450 painter
.setBrush(self
.brush
)
1451 painter
.drawEllipse(self
.inner_rect
)
1453 def mousePressEvent(self
, event
):
1454 QtWidgets
.QGraphicsItem
.mousePressEvent(self
, event
)
1456 self
.selected
= self
.isSelected()
1458 def mouseMoveEvent(self
, event
):
1461 QtWidgets
.QGraphicsItem
.mouseMoveEvent(self
, event
)
1463 def mouseReleaseEvent(self
, event
):
1464 QtWidgets
.QGraphicsItem
.mouseReleaseEvent(self
, event
)
1465 if not self
.dragged
and self
.selected
and event
.button() == Qt
.LeftButton
:
1467 self
.pressed
= False
1468 self
.dragged
= False
1471 class Label(QtWidgets
.QGraphicsItem
):
1472 item_type
= qtutils
.graphics_item_type_value(3)
1474 head_color
= QtGui
.QColor(Qt
.green
)
1475 other_color
= QtGui
.QColor(Qt
.white
)
1476 remote_color
= QtGui
.QColor(Qt
.yellow
)
1478 head_pen
= QtGui
.QPen()
1479 head_pen
.setColor(QtGui
.QColor(Qt
.black
))
1480 head_pen
.setWidth(1)
1482 text_pen
= QtGui
.QPen()
1483 text_pen
.setColor(QtGui
.QColor(Qt
.black
))
1484 text_pen
.setWidth(1)
1491 def __init__(self
, commit
):
1492 QtWidgets
.QGraphicsItem
.__init
__(self
)
1494 self
.commit
= commit
1497 return self
.item_type
1499 def boundingRect(self
, cache
=Cache
):
1500 QPainterPath
= QtGui
.QPainterPath
1501 QRectF
= QtCore
.QRectF
1506 spacing
= self
.item_spacing
1507 border_x
= self
.border
+ self
.text_x_offset
1508 border_y
= self
.border
+ self
.text_y_offset
1510 font
= cache
.label_font()
1511 item_shape
= QPainterPath()
1513 base_rect
= QRectF(0, 0, width
, height
)
1514 base_rect
= base_rect
.adjusted(-border_x
, -border_y
, border_x
, border_y
)
1515 item_shape
.addRect(base_rect
)
1517 for tag
in self
.commit
.tags
:
1518 text_shape
= QPainterPath()
1519 text_shape
.addText(current_width
, 0, font
, tag
)
1520 text_rect
= text_shape
.boundingRect()
1521 box_rect
= text_rect
.adjusted(-border_x
, -border_y
, border_x
, border_y
)
1522 item_shape
.addRect(box_rect
)
1523 current_width
= item_shape
.boundingRect().width() + spacing
1525 return item_shape
.boundingRect()
1527 def paint(self
, painter
, _option
, _widget
, cache
=Cache
):
1528 # Draw tags and branches
1529 font
= cache
.label_font()
1530 painter
.setFont(font
)
1533 border
= self
.border
1534 x_offset
= self
.text_x_offset
1535 y_offset
= self
.text_y_offset
1536 spacing
= self
.item_spacing
1537 QRectF
= QtCore
.QRectF
1540 remotes_prefix
= 'remotes/'
1541 tags_prefix
= 'tags/'
1542 heads_prefix
= 'heads/'
1543 remotes_len
= len(remotes_prefix
)
1544 tags_len
= len(tags_prefix
)
1545 heads_len
= len(heads_prefix
)
1547 for tag
in self
.commit
.tags
:
1549 painter
.setPen(self
.text_pen
)
1550 painter
.setBrush(self
.remote_color
)
1551 elif tag
.startswith(remotes_prefix
):
1552 tag
= tag
[remotes_len
:]
1553 painter
.setPen(self
.text_pen
)
1554 painter
.setBrush(self
.other_color
)
1555 elif tag
.startswith(tags_prefix
):
1556 tag
= tag
[tags_len
:]
1557 painter
.setPen(self
.text_pen
)
1558 painter
.setBrush(self
.remote_color
)
1559 elif tag
.startswith(heads_prefix
):
1560 tag
= tag
[heads_len
:]
1561 painter
.setPen(self
.head_pen
)
1562 painter
.setBrush(self
.head_color
)
1564 painter
.setPen(self
.text_pen
)
1565 painter
.setBrush(self
.other_color
)
1567 text_rect
= painter
.boundingRect(
1568 QRectF(current_width
, 0, 0, 0), Qt
.TextSingleLine
, tag
1570 box_rect
= text_rect
.adjusted(-x_offset
, -y_offset
, x_offset
, y_offset
)
1572 painter
.drawRoundedRect(box_rect
, border
, border
)
1573 painter
.drawText(text_rect
, Qt
.TextSingleLine
, tag
)
1574 current_width
+= text_rect
.width() + spacing
1577 class GraphView(QtWidgets
.QGraphicsView
, ViewerMixin
):
1578 commits_selected
= Signal(object)
1579 diff_commits
= Signal(object, object)
1581 x_adjust
= int(Commit
.commit_radius
* 4 / 3)
1582 y_adjust
= int(Commit
.commit_radius
* 4 / 3)
1587 def __init__(self
, context
, parent
):
1588 QtWidgets
.QGraphicsView
.__init
__(self
, parent
)
1589 ViewerMixin
.__init
__(self
)
1590 EdgeColor
.update_colors(context
.app
.theme
)
1592 theme
= context
.app
.theme
1593 highlight
= theme
.selection_color()
1594 Commit
.commit_selected_color
= highlight
1595 Commit
.selected_outline_color
= highlight
.darker()
1597 self
.context
= context
1599 self
.menu_actions
= None
1602 self
.mouse_start
= [0, 0]
1603 self
.saved_matrix
= self
.transform()
1607 self
.tagged_cells
= set()
1611 self
.x_offsets
= collections
.defaultdict(lambda: self
.x_min
)
1613 self
.is_panning
= False
1614 self
.pressed
= False
1615 self
.selecting
= False
1616 self
.last_mouse
= [0, 0]
1618 self
.setDragMode(self
.RubberBandDrag
)
1620 scene
= QtWidgets
.QGraphicsScene(self
)
1621 scene
.setItemIndexMethod(QtWidgets
.QGraphicsScene
.BspTreeIndex
)
1622 scene
.selectionChanged
.connect(self
.selection_changed
)
1623 self
.setScene(scene
)
1625 self
.setRenderHint(QtGui
.QPainter
.Antialiasing
)
1626 self
.setViewportUpdateMode(self
.SmartViewportUpdate
)
1627 self
.setCacheMode(QtWidgets
.QGraphicsView
.CacheBackground
)
1628 self
.setTransformationAnchor(QtWidgets
.QGraphicsView
.AnchorUnderMouse
)
1629 self
.setResizeAnchor(QtWidgets
.QGraphicsView
.NoAnchor
)
1631 background_color
= qtutils
.css_color(context
.app
.theme
.background_color_rgb())
1632 self
.setBackgroundBrush(background_color
)
1639 hotkeys
.ZOOM_IN_SECONDARY
,
1642 qtutils
.add_action(self
, N_('Zoom Out'), self
.zoom_out
, hotkeys
.ZOOM_OUT
)
1644 qtutils
.add_action(self
, N_('Zoom to Fit'), self
.zoom_to_fit
, hotkeys
.FIT
)
1647 self
, N_('Select Parent'), self
._select
_parent
, hotkeys
.MOVE_DOWN_TERTIARY
1652 N_('Select Oldest Parent'),
1653 self
._select
_oldest
_parent
,
1658 self
, N_('Select Child'), self
._select
_child
, hotkeys
.MOVE_UP_TERTIARY
1662 self
, N_('Select Newest Child'), self
._select
_newest
_child
, hotkeys
.MOVE_UP
1667 self
.scene().clear()
1669 self
.x_offsets
.clear()
1673 # ViewerMixin interface
1674 def selected_items(self
):
1675 """Return the currently selected items"""
1676 return self
.scene().selectedItems()
1679 self
.scale_view(1.5)
1682 self
.scale_view(1.0 / 1.5)
1684 def selection_changed(self
):
1685 # Broadcast selection to other widgets
1686 selected_items
= self
.scene().selectedItems()
1687 commits
= sort_by_generation([item
.commit
for item
in selected_items
])
1688 self
.set_selecting(True)
1689 self
.commits_selected
.emit(commits
)
1690 self
.set_selecting(False)
1692 def select_commits(self
, commits
):
1695 with qtutils
.BlockSignals(self
.scene()):
1696 self
.select([commit
.oid
for commit
in commits
])
1698 def select(self
, oids
):
1699 """Select the item for the oids"""
1700 self
.scene().clearSelection()
1703 item
= self
.items
[oid
]
1706 item
.setSelected(True)
1707 item_rect
= item
.sceneTransform().mapRect(item
.boundingRect())
1708 self
.ensureVisible(item_rect
)
1710 def _get_item_by_generation(self
, commits
, criteria_func
):
1711 """Return the item for the commit matching criteria"""
1715 for commit
in commits
:
1716 if generation
is None or criteria_func(generation
, commit
.generation
):
1718 generation
= commit
.generation
1720 return self
.items
[oid
]
1724 def _oldest_item(self
, commits
):
1725 """Return the item for the commit with the oldest generation number"""
1726 return self
._get
_item
_by
_generation
(commits
, lambda a
, b
: a
> b
)
1728 def _newest_item(self
, commits
):
1729 """Return the item for the commit with the newest generation number"""
1730 return self
._get
_item
_by
_generation
(commits
, lambda a
, b
: a
< b
)
1732 def create_patch(self
):
1733 items
= self
.selected_items()
1736 context
= self
.context
1737 selected_commits
= sort_by_generation([n
.commit
for n
in items
])
1738 oids
= [c
.oid
for c
in selected_commits
]
1739 all_oids
= [c
.oid
for c
in sort_by_generation(self
.commits
)]
1740 cmds
.do(cmds
.FormatPatch
, context
, oids
, all_oids
)
1742 def _select_parent(self
):
1743 """Select the parent with the newest generation number"""
1744 selected_item
= self
.selected_item()
1745 if selected_item
is None:
1747 parent_item
= self
._newest
_item
(selected_item
.commit
.parents
)
1748 if parent_item
is None:
1750 selected_item
.setSelected(False)
1751 parent_item
.setSelected(True)
1752 self
.ensureVisible(parent_item
.mapRectToScene(parent_item
.boundingRect()))
1754 def _select_oldest_parent(self
):
1755 """Select the parent with the oldest generation number"""
1756 selected_item
= self
.selected_item()
1757 if selected_item
is None:
1759 parent_item
= self
._oldest
_item
(selected_item
.commit
.parents
)
1760 if parent_item
is None:
1762 selected_item
.setSelected(False)
1763 parent_item
.setSelected(True)
1764 scene_rect
= parent_item
.mapRectToScene(parent_item
.boundingRect())
1765 self
.ensureVisible(scene_rect
)
1767 def _select_child(self
):
1768 """Select the child with the oldest generation number"""
1769 selected_item
= self
.selected_item()
1770 if selected_item
is None:
1772 child_item
= self
._oldest
_item
(selected_item
.commit
.children
)
1773 if child_item
is None:
1775 selected_item
.setSelected(False)
1776 child_item
.setSelected(True)
1777 scene_rect
= child_item
.mapRectToScene(child_item
.boundingRect())
1778 self
.ensureVisible(scene_rect
)
1780 def _select_newest_child(self
):
1781 """Select the Nth child with the newest generation number (N > 1)"""
1782 selected_item
= self
.selected_item()
1783 if selected_item
is None:
1785 if len(selected_item
.commit
.children
) > 1:
1786 children
= selected_item
.commit
.children
[1:]
1788 children
= selected_item
.commit
.children
1789 child_item
= self
._newest
_item
(children
)
1790 if child_item
is None:
1792 selected_item
.setSelected(False)
1793 child_item
.setSelected(True)
1794 scene_rect
= child_item
.mapRectToScene(child_item
.boundingRect())
1795 self
.ensureVisible(scene_rect
)
1797 def set_initial_view(self
):
1799 selected
= self
.selected_items()
1801 items
.extend(selected
)
1803 if not selected
and self
.commits
:
1804 commit
= self
.commits
[-1]
1805 items
.append(self
.items
[commit
.oid
])
1807 bounds
= self
.scene().itemsBoundingRect()
1808 bounds
.adjust(-64, 0, 0, 0)
1809 self
.setSceneRect(bounds
)
1810 self
.fit_view_to_items(items
)
1812 def zoom_to_fit(self
):
1813 """Fit selected items into the viewport"""
1814 items
= self
.selected_items()
1815 self
.fit_view_to_items(items
)
1817 def fit_view_to_items(self
, items
):
1819 rect
= self
.scene().itemsBoundingRect()
1821 x_min
= y_min
= maxsize
1822 x_max
= y_max
= -maxsize
1828 x_min
= min(x_min
, x_val
)
1829 x_max
= max(x_max
, x_val
)
1830 y_min
= min(y_min
, y_val
)
1831 y_max
= max(y_max
, y_val
)
1833 rect
= QtCore
.QRectF(x_min
, y_min
, abs(x_max
- x_min
), abs(y_max
- y_min
))
1835 x_adjust
= abs(GraphView
.x_adjust
)
1836 y_adjust
= abs(GraphView
.y_adjust
)
1838 count
= max(2.0, 10.0 - len(items
) / 2.0)
1839 y_offset
= int(y_adjust
* count
)
1840 x_offset
= int(x_adjust
* count
)
1841 rect
.setX(rect
.x() - x_offset
// 2)
1842 rect
.setY(rect
.y() - y_adjust
// 2)
1843 rect
.setHeight(rect
.height() + y_offset
)
1844 rect
.setWidth(rect
.width() + x_offset
)
1846 self
.fitInView(rect
, Qt
.KeepAspectRatio
)
1847 self
.scene().invalidate()
1849 def handle_event(self
, event_handler
, event
, update
=True):
1850 event_handler(self
, event
)
1854 def set_selecting(self
, selecting
):
1855 self
.selecting
= selecting
1857 def pan(self
, event
):
1859 x_offset
= pos
.x() - self
.mouse_start
[0]
1860 y_offset
= pos
.y() - self
.mouse_start
[1]
1862 if x_offset
== 0 and y_offset
== 0:
1865 rect
= QtCore
.QRect(0, 0, abs(x_offset
), abs(y_offset
))
1866 delta
= self
.mapToScene(rect
).boundingRect()
1868 x_translate
= delta
.width()
1870 x_translate
= -x_translate
1872 y_translate
= delta
.height()
1874 y_translate
= -y_translate
1876 matrix
= self
.transform()
1878 matrix
*= self
.saved_matrix
1879 matrix
.translate(x_translate
, y_translate
)
1881 self
.setTransformationAnchor(QtWidgets
.QGraphicsView
.NoAnchor
)
1882 self
.setTransform(matrix
)
1884 def wheel_zoom(self
, event
):
1885 """Handle mouse wheel zooming."""
1886 delta
= qtcompat
.wheel_delta(event
)
1887 zoom
= math
.pow(2.0, delta
/ 512.0)
1891 .mapRect(QtCore
.QRectF(0.0, 0.0, 1.0, 1.0))
1894 if factor
< 0.014 or factor
> 42.0:
1896 self
.setTransformationAnchor(QtWidgets
.QGraphicsView
.AnchorUnderMouse
)
1898 self
.scale(zoom
, zoom
)
1900 def wheel_pan(self
, event
):
1901 """Handle mouse wheel panning."""
1902 unit
= QtCore
.QRectF(0.0, 0.0, 1.0, 1.0)
1903 factor
= 1.0 / self
.transform().mapRect(unit
).width()
1904 tx
, ty
= qtcompat
.wheel_translation(event
)
1906 matrix
= self
.transform().translate(tx
* factor
, ty
* factor
)
1907 self
.setTransformationAnchor(QtWidgets
.QGraphicsView
.NoAnchor
)
1908 self
.setTransform(matrix
)
1910 def scale_view(self
, scale
):
1913 .scale(scale
, scale
)
1914 .mapRect(QtCore
.QRectF(0, 0, 1, 1))
1917 if factor
< 0.07 or factor
> 100.0:
1921 adjust_scrollbars
= False
1922 scrollbar
= self
.verticalScrollBar()
1923 scrollbar_offset
= 1.0
1925 value
= get(scrollbar
)
1926 minimum
= scrollbar
.minimum()
1927 maximum
= scrollbar
.maximum()
1928 scrollbar_range
= maximum
- minimum
1929 distance
= value
- minimum
1930 nonzero_range
= scrollbar_range
> 0.1
1932 scrollbar_offset
= distance
/ scrollbar_range
1933 adjust_scrollbars
= True
1935 self
.setTransformationAnchor(QtWidgets
.QGraphicsView
.NoAnchor
)
1936 self
.scale(scale
, scale
)
1938 scrollbar
= self
.verticalScrollBar()
1939 if scrollbar
and adjust_scrollbars
:
1940 minimum
= scrollbar
.minimum()
1941 maximum
= scrollbar
.maximum()
1942 scrollbar_range
= maximum
- minimum
1943 value
= minimum
+ int(float(scrollbar_range
) * scrollbar_offset
)
1944 scrollbar
.setValue(value
)
1946 def add_commits(self
, commits
):
1947 """Traverse commits and add them to the view."""
1948 self
.commits
.extend(commits
)
1949 scene
= self
.scene()
1950 for commit
in commits
:
1951 item
= Commit(commit
)
1952 self
.items
[commit
.oid
] = item
1953 for ref
in commit
.tags
:
1954 self
.items
[ref
] = item
1957 self
.layout_commits()
1960 def link(self
, commits
):
1961 """Create edges linking commits with their parents"""
1962 scene
= self
.scene()
1963 for commit
in commits
:
1965 commit_item
= self
.items
[commit
.oid
]
1967 continue # The history is truncated.
1968 for parent
in reversed(commit
.parents
):
1970 parent_item
= self
.items
[parent
.oid
]
1972 continue # The history is truncated.
1974 edge
= parent_item
.edges
[commit
.oid
]
1976 edge
= Edge(parent_item
, commit_item
)
1979 parent_item
.edges
[commit
.oid
] = edge
1980 commit_item
.edges
[parent
.oid
] = edge
1983 def layout_commits(self
):
1984 positions
= self
.position_nodes()
1986 # Each edge is accounted in two commits. Hence, accumulate invalid
1987 # edges to prevent double edge invalidation.
1988 invalid_edges
= set()
1990 for oid
, (x_val
, y_val
) in positions
.items():
1991 item
= self
.items
[oid
]
1994 if pos
!= (x_val
, y_val
):
1995 item
.setPos(x_val
, y_val
)
1997 for edge
in item
.edges
.values():
1998 invalid_edges
.add(edge
)
2000 for edge
in invalid_edges
:
2001 edge
.commits_were_invalidated()
2003 # Commit node layout technique
2005 # Nodes are aligned by a mesh. Columns and rows are distributed using
2006 # algorithms described below.
2008 # Row assignment algorithm
2010 # The algorithm aims consequent.
2011 # 1. A commit should be above all its parents.
2012 # 2. No commit should be at right side of a commit with a tag in same row.
2013 # This prevents overlapping of tag labels with commits and other labels.
2014 # 3. Commit density should be maximized.
2016 # The algorithm requires that all parents of a commit were assigned column.
2017 # Nodes must be traversed in generation ascend order. This guarantees that all
2018 # parents of a commit were assigned row. So, the algorithm may operate in
2019 # course of column assignment algorithm.
2021 # Row assignment uses frontier. A frontier is a dictionary that contains
2022 # minimum available row index for each column. It propagates during the
2023 # algorithm. Set of cells with tags is also maintained to meet second aim.
2025 # Initialization is performed by reset_rows method. Each new column should
2026 # be declared using declare_column method. Getting row for a cell is
2027 # implemented in alloc_cell method. Frontier must be propagated for any child
2028 # of fork commit which occupies different column. This meets first aim.
2030 # Column assignment algorithm
2032 # The algorithm traverses nodes in generation ascend order. This guarantees
2033 # that a node will be visited after all its parents.
2035 # The set of occupied columns are maintained during work. Initially it is
2036 # empty and no node occupied a column. Empty columns are allocated on demand.
2037 # Free index for column being allocated is searched in following way.
2038 # 1. Start from desired column and look towards graph center (0 column).
2039 # 2. Start from center and look in both directions simultaneously.
2040 # Desired column is defaulted to 0. Fork node should set desired column for
2041 # children equal to its one. This prevents branch from jumping too far from
2044 # Initialization is performed by reset_columns method. Column allocation is
2045 # implemented in alloc_column method. Initialization and main loop are in
2046 # recompute_grid method. The method also embeds row assignment algorithm by
2049 # Actions for each node are follow.
2050 # 1. If the node was not assigned a column then it is assigned empty one.
2052 # 3. Allocate columns for children.
2053 # If a child have a column assigned then it should no be overridden. One of
2054 # children is assigned same column as the node. If the node is a fork then the
2055 # child is chosen in generation descent order. This is a heuristic and it only
2056 # affects resulting appearance of the graph. Other children are assigned empty
2057 # columns in same order. It is the heuristic too.
2058 # 4. If no child occupies column of the node then leave it.
2059 # It is possible in consequent situations.
2060 # 4.1 The node is a leaf.
2061 # 4.2 The node is a fork and all its children are already assigned side
2062 # column. It is possible if all the children are merges.
2063 # 4.3 Single node child is a merge that is already assigned a column.
2064 # 5. Propagate frontier with respect to this node.
2065 # Each frontier entry corresponding to column occupied by any node's child
2066 # must be gather than node row index. This meets first aim of the row
2067 # assignment algorithm.
2068 # Note that frontier of child that occupies same row was propagated during
2069 # step 2. Hence, it must be propagated for children on side columns.
2071 def reset_columns(self
):
2072 # Some children of displayed commits might not be accounted in
2073 # 'commits' list. It is common case during loading of big graph.
2074 # But, they are assigned a column that must be reset. Hence, use
2075 # depth-first traversal to reset all columns assigned.
2076 for node
in self
.commits
:
2077 if node
.column
is None:
2083 for child
in node
.children
:
2084 if child
.column
is not None:
2091 def reset_rows(self
):
2093 self
.tagged_cells
= set()
2095 def declare_column(self
, column
):
2097 # Align new column frontier by frontier of nearest column. If all
2098 # columns were left then select maximum frontier value.
2099 if not self
.columns
:
2100 self
.frontier
[column
] = max(list(self
.frontier
.values()))
2102 # This is heuristic that mostly affects roots. Note that the
2103 # frontier values for fork children will be overridden in course of
2104 # propagate_frontier.
2105 for offset
in itertools
.count(1):
2106 for value
in (column
+ offset
, column
- offset
):
2107 if value
not in self
.columns
:
2108 # Column is not occupied.
2111 frontier
= self
.frontier
[value
]
2113 # Column 'c' was never allocated.
2117 # The frontier of the column may be higher because of
2118 # tag overlapping prevention performed for previous head.
2120 if self
.frontier
[column
] >= frontier
:
2125 self
.frontier
[column
] = frontier
2131 # First commit must be assigned 0 row.
2132 self
.frontier
[column
] = 0
2134 def alloc_column(self
, column
=0):
2135 columns
= self
.columns
2136 # First, look for free column by moving from desired column to graph
2137 # center (column 0).
2138 for c
in range(column
, 0, -1 if column
> 0 else 1):
2139 if c
not in columns
:
2140 if c
> self
.max_column
:
2142 elif c
< self
.min_column
:
2146 # If no free column was found between graph center and desired
2147 # column then look for free one by moving from center along both
2148 # directions simultaneously.
2149 for c
in itertools
.count(0):
2150 if c
not in columns
:
2151 if c
> self
.max_column
:
2155 if c
not in columns
:
2156 if c
< self
.min_column
:
2159 self
.declare_column(c
)
2163 def alloc_cell(self
, column
, tags
):
2164 # Get empty cell from frontier.
2165 cell_row
= self
.frontier
[column
]
2168 # Prevent overlapping of tag with cells already allocated a row.
2170 can_overlap
= list(range(column
+ 1, self
.max_column
+ 1))
2172 can_overlap
= list(range(column
- 1, self
.min_column
- 1, -1))
2173 for value
in can_overlap
:
2174 frontier
= self
.frontier
[value
]
2175 if frontier
> cell_row
:
2178 # Avoid overlapping with tags of commits at cell_row.
2180 can_overlap
= list(range(self
.min_column
, column
))
2182 can_overlap
= list(range(self
.max_column
, column
, -1))
2183 for cell_row
in itertools
.count(cell_row
):
2184 for value
in can_overlap
:
2185 if (value
, cell_row
) in self
.tagged_cells
:
2186 # Overlapping. Try next row.
2189 # No overlapping was found.
2191 # Note that all checks should be made for new cell_row value.
2194 self
.tagged_cells
.add((column
, cell_row
))
2196 # Propagate frontier.
2197 self
.frontier
[column
] = cell_row
+ 1
2200 def propagate_frontier(self
, column
, value
):
2201 current
= self
.frontier
[column
]
2203 self
.frontier
[column
] = value
2205 def leave_column(self
, column
):
2206 count
= self
.columns
[column
]
2208 del self
.columns
[column
]
2210 self
.columns
[column
] = count
- 1
2212 def recompute_grid(self
):
2213 self
.reset_columns()
2216 for node
in sort_by_generation(list(self
.commits
)):
2217 if node
.column
is None:
2218 # Node is either root or its parent is not in items. This
2219 # happens when tree loading is in progress. Allocate new
2220 # columns for such nodes.
2221 node
.column
= self
.alloc_column()
2223 node
.row
= self
.alloc_cell(node
.column
, node
.tags
)
2225 # Allocate columns for children which are still without one. Also
2226 # propagate frontier for children.
2228 sorted_children
= sorted(
2229 node
.children
, key
=lambda c
: c
.generation
, reverse
=True
2231 citer
= iter(sorted_children
)
2233 if child
.column
is None:
2234 # Top most child occupies column of parent.
2235 child
.column
= node
.column
2236 # Note that frontier is propagated in course of
2239 self
.propagate_frontier(child
.column
, node
.row
+ 1)
2241 # No child occupies same column.
2242 self
.leave_column(node
.column
)
2243 # Note that the loop below will pass no iteration.
2245 # Rest children are allocated new column.
2247 if child
.column
is None:
2248 child
.column
= self
.alloc_column(node
.column
)
2249 self
.propagate_frontier(child
.column
, node
.row
+ 1)
2251 child
= node
.children
[0]
2252 if child
.column
is None:
2253 child
.column
= node
.column
2254 # Note that frontier is propagated in course of alloc_cell.
2255 elif child
.column
!= node
.column
:
2256 # Child node have other parents and occupies column of one
2258 self
.leave_column(node
.column
)
2259 # But frontier must be propagated with respect to this
2261 self
.propagate_frontier(child
.column
, node
.row
+ 1)
2263 # This is a leaf node.
2264 self
.leave_column(node
.column
)
2266 def position_nodes(self
):
2267 self
.recompute_grid()
2269 x_start
= self
.x_start
2276 for node
in self
.commits
:
2277 x_val
= x_start
+ node
.column
* x_off
2278 y_val
= y_off
+ node
.row
* y_off
2280 positions
[node
.oid
] = (x_val
, y_val
)
2281 x_min
= min(x_min
, x_val
)
2288 def contextMenuEvent(self
, event
):
2289 self
.context_menu_event(event
)
2291 def mousePressEvent(self
, event
):
2292 if event
.button() == Qt
.MidButton
:
2294 self
.mouse_start
= [pos
.x(), pos
.y()]
2295 self
.saved_matrix
= self
.transform()
2296 self
.is_panning
= True
2298 if event
.button() == Qt
.RightButton
:
2301 if event
.button() == Qt
.LeftButton
:
2303 self
.handle_event(QtWidgets
.QGraphicsView
.mousePressEvent
, event
)
2305 def mouseMoveEvent(self
, event
):
2309 pos
= self
.mapToScene(event
.pos())
2310 self
.last_mouse
[0] = pos
.x()
2311 self
.last_mouse
[1] = pos
.y()
2312 self
.handle_event(QtWidgets
.QGraphicsView
.mouseMoveEvent
, event
, update
=False)
2314 def mouseReleaseEvent(self
, event
):
2315 self
.pressed
= False
2316 if event
.button() == Qt
.MidButton
:
2317 self
.is_panning
= False
2319 self
.handle_event(QtWidgets
.QGraphicsView
.mouseReleaseEvent
, event
)
2320 self
.viewport().repaint()
2322 def wheelEvent(self
, event
):
2323 """Handle Qt mouse wheel events."""
2324 if event
.modifiers() & Qt
.ControlModifier
:
2325 self
.wheel_zoom(event
)
2327 self
.wheel_pan(event
)
2329 def fitInView(self
, rect
, flags
=Qt
.IgnoreAspectRatio
):
2330 """Override fitInView to remove unwanted margins
2332 https://bugreports.qt.io/browse/QTBUG-42331 - based on QT sources
2335 if self
.scene() is None or rect
.isNull():
2337 unity
= self
.transform().mapRect(QtCore
.QRectF(0, 0, 1, 1))
2338 self
.scale(1.0 / unity
.width(), 1.0 / unity
.height())
2339 view_rect
= self
.viewport().rect()
2340 scene_rect
= self
.transform().mapRect(rect
)
2341 xratio
= view_rect
.width() / scene_rect
.width()
2342 yratio
= view_rect
.height() / scene_rect
.height()
2343 if flags
== Qt
.KeepAspectRatio
:
2344 xratio
= yratio
= min(xratio
, yratio
)
2345 elif flags
== Qt
.KeepAspectRatioByExpanding
:
2346 xratio
= yratio
= max(xratio
, yratio
)
2347 self
.scale(xratio
, yratio
)
2348 self
.centerOn(rect
.center())
2351 def sort_by_generation(commits
):
2352 """Sort commits by their generation. Ensures consistent diffs and patch exports"""
2353 if len(commits
) <= 1:
2355 commits
.sort(key
=lambda x
: x
.generation
)
2361 # oid -- Git objects IDs (i.e. SHA-1 IDs)
2362 # ref -- Git references that resolve to a commit-ish (HEAD, branches, tags)