1 from __future__
import division
, absolute_import
, unicode_literals
5 from functools
import partial
7 from qtpy
.QtCore
import Qt
8 from qtpy
.QtCore
import Signal
9 from qtpy
import QtCore
10 from qtpy
import QtGui
11 from qtpy
import QtWidgets
13 from ..compat
import maxsize
15 from ..models
import dag
16 from ..qtutils
import get
19 from .. import difftool
20 from .. import gitcmds
21 from .. import hotkeys
23 from .. import observable
24 from .. import qtcompat
25 from .. import qtutils
29 from . import completion
30 from . import createbranch
31 from . import createtag
34 from . import filelist
35 from . import standard
38 def git_dag(context
, args
=None, settings
=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
, settings
=settings
)
51 view
.set_params(params
)
59 class FocusRedirectProxy(object):
60 """Redirect actions from the main widget to child widgets"""
62 def __init__(self
, *widgets
):
63 """Provide proxied widgets; the default widget must be first"""
64 self
.widgets
= widgets
65 self
.default
= widgets
[0]
67 def __getattr__(self
, name
):
68 return (lambda *args
, **kwargs
:
69 self
._forward
_action
(name
, *args
, **kwargs
))
71 def _forward_action(self
, name
, *args
, **kwargs
):
72 """Forward the captured action to the focused or default widget"""
73 widget
= QtWidgets
.QApplication
.focusWidget()
74 if widget
in self
.widgets
and hasattr(widget
, name
):
75 fn
= getattr(widget
, name
)
77 fn
= getattr(self
.default
, name
)
79 return fn(*args
, **kwargs
)
82 class ViewerMixin(object):
83 """Implementations must provide selected_items()"""
86 self
.context
= None # provided by implementation
89 self
.menu_actions
= None # provided by implementation
91 def selected_item(self
):
92 """Return the currently selected item"""
93 selected_items
= self
.selected_items()
94 if not selected_items
:
96 return selected_items
[0]
98 def selected_oid(self
):
99 item
= self
.selected_item()
103 result
= item
.commit
.oid
106 def selected_oids(self
):
107 return [i
.commit
for i
in self
.selected_items()]
109 def with_oid(self
, fn
):
110 oid
= self
.selected_oid()
117 def diff_selected_this(self
):
118 clicked_oid
= self
.clicked
.oid
119 selected_oid
= self
.selected
.oid
120 self
.diff_commits
.emit(selected_oid
, clicked_oid
)
122 def diff_this_selected(self
):
123 clicked_oid
= self
.clicked
.oid
124 selected_oid
= self
.selected
.oid
125 self
.diff_commits
.emit(clicked_oid
, selected_oid
)
127 def cherry_pick(self
):
128 context
= self
.context
129 self
.with_oid(lambda oid
: cmds
.do(cmds
.CherryPick
, context
, [oid
]))
132 context
= self
.context
133 self
.with_oid(lambda oid
: cmds
.do(cmds
.Revert
, context
, oid
))
135 def copy_to_clipboard(self
):
136 self
.with_oid(qtutils
.set_clipboard
)
138 def create_branch(self
):
139 context
= self
.context
140 create_new_branch
= partial(createbranch
.create_new_branch
, context
)
141 self
.with_oid(lambda oid
: create_new_branch(revision
=oid
))
143 def create_tag(self
):
144 context
= self
.context
145 self
.with_oid(lambda oid
: createtag
.create_tag(context
, ref
=oid
))
147 def create_tarball(self
):
148 context
= self
.context
150 lambda oid
: archive
.show_save_dialog(context
, oid
, parent
=self
))
153 context
= self
.context
154 self
.with_oid(lambda oid
:
155 difftool
.diff_expression(context
, self
, oid
+ '^!',
159 def show_dir_diff(self
):
160 context
= self
.context
161 self
.with_oid(lambda oid
:
162 cmds
.difftool_launch(context
, left
=oid
,
163 left_take_magic
=True,
166 def reset_branch_head(self
):
167 context
= self
.context
168 self
.with_oid(lambda oid
:
169 cmds
.do(cmds
.ResetBranchHead
, context
, ref
=oid
))
171 def reset_worktree(self
):
172 context
= self
.context
173 self
.with_oid(lambda oid
:
174 cmds
.do(cmds
.ResetWorktree
, context
, ref
=oid
))
176 def reset_merge(self
):
177 context
= self
.context
178 self
.with_oid(lambda oid
: cmds
.do(cmds
.ResetMerge
, context
, ref
=oid
))
180 def reset_soft(self
):
181 context
= self
.context
182 self
.with_oid(lambda oid
: cmds
.do(cmds
.ResetSoft
, context
, ref
=oid
))
184 def reset_hard(self
):
185 context
= self
.context
186 self
.with_oid(lambda oid
: cmds
.do(cmds
.ResetHard
, context
, ref
=oid
))
188 def checkout_detached(self
):
189 context
= self
.context
190 self
.with_oid(lambda oid
: cmds
.do(cmds
.Checkout
, context
, [oid
]))
192 def save_blob_dialog(self
):
193 context
= self
.context
194 self
.with_oid(lambda oid
: browse
.BrowseBranch
.browse(context
, oid
))
196 def update_menu_actions(self
, event
):
197 selected_items
= self
.selected_items()
198 item
= self
.itemAt(event
.pos())
200 self
.clicked
= commit
= None
202 self
.clicked
= commit
= item
.commit
204 has_single_selection
= len(selected_items
) == 1
205 has_selection
= bool(selected_items
)
206 can_diff
= bool(commit
and has_single_selection
and
207 commit
is not selected_items
[0].commit
)
210 self
.selected
= selected_items
[0].commit
214 self
.menu_actions
['diff_this_selected'].setEnabled(can_diff
)
215 self
.menu_actions
['diff_selected_this'].setEnabled(can_diff
)
216 self
.menu_actions
['diff_commit'].setEnabled(has_single_selection
)
217 self
.menu_actions
['diff_commit_all'].setEnabled(has_single_selection
)
219 self
.menu_actions
['checkout_detached'].setEnabled(has_single_selection
)
220 self
.menu_actions
['cherry_pick'].setEnabled(has_single_selection
)
221 self
.menu_actions
['copy'].setEnabled(has_single_selection
)
222 self
.menu_actions
['create_branch'].setEnabled(has_single_selection
)
223 self
.menu_actions
['create_patch'].setEnabled(has_selection
)
224 self
.menu_actions
['create_tag'].setEnabled(has_single_selection
)
225 self
.menu_actions
['create_tarball'].setEnabled(has_single_selection
)
226 self
.menu_actions
['reset_branch_head'].setEnabled(has_single_selection
)
227 self
.menu_actions
['reset_worktree'].setEnabled(has_single_selection
)
228 self
.menu_actions
['reset_merge'].setEnabled(has_single_selection
)
229 self
.menu_actions
['reset_soft'].setEnabled(has_single_selection
)
230 self
.menu_actions
['reset_hard'].setEnabled(has_single_selection
)
231 self
.menu_actions
['revert'].setEnabled(has_single_selection
)
232 self
.menu_actions
['save_blob'].setEnabled(has_single_selection
)
234 def context_menu_event(self
, event
):
235 self
.update_menu_actions(event
)
236 menu
= qtutils
.create_menu(N_('Actions'), self
)
237 menu
.addAction(self
.menu_actions
['diff_this_selected'])
238 menu
.addAction(self
.menu_actions
['diff_selected_this'])
239 menu
.addAction(self
.menu_actions
['diff_commit'])
240 menu
.addAction(self
.menu_actions
['diff_commit_all'])
242 menu
.addAction(self
.menu_actions
['create_branch'])
243 menu
.addAction(self
.menu_actions
['create_tag'])
245 menu
.addAction(self
.menu_actions
['cherry_pick'])
246 menu
.addAction(self
.menu_actions
['revert'])
247 menu
.addAction(self
.menu_actions
['create_patch'])
248 menu
.addAction(self
.menu_actions
['create_tarball'])
250 reset_menu
= menu
.addMenu(N_('Reset'))
251 reset_menu
.addAction(self
.menu_actions
['reset_branch_head'])
252 reset_menu
.addAction(self
.menu_actions
['reset_worktree'])
253 reset_menu
.addSeparator()
254 reset_menu
.addAction(self
.menu_actions
['reset_merge'])
255 reset_menu
.addAction(self
.menu_actions
['reset_soft'])
256 reset_menu
.addAction(self
.menu_actions
['reset_hard'])
257 menu
.addAction(self
.menu_actions
['checkout_detached'])
259 menu
.addAction(self
.menu_actions
['save_blob'])
260 menu
.addAction(self
.menu_actions
['copy'])
261 menu
.exec_(self
.mapToGlobal(event
.pos()))
264 def viewer_actions(widget
):
266 'diff_this_selected':
267 qtutils
.add_action(widget
, N_('Diff this -> selected'),
268 widget
.proxy
.diff_this_selected
),
269 'diff_selected_this':
270 qtutils
.add_action(widget
, N_('Diff selected -> this'),
271 widget
.proxy
.diff_selected_this
),
273 qtutils
.add_action(widget
, N_('Create Branch'),
274 widget
.proxy
.create_branch
),
276 qtutils
.add_action(widget
, N_('Create Patch'),
277 widget
.proxy
.create_patch
),
279 qtutils
.add_action(widget
, N_('Create Tag'),
280 widget
.proxy
.create_tag
),
282 qtutils
.add_action(widget
, N_('Save As Tarball/Zip...'),
283 widget
.proxy
.create_tarball
),
285 qtutils
.add_action(widget
, N_('Cherry Pick'),
286 widget
.proxy
.cherry_pick
),
288 qtutils
.add_action(widget
, N_('Revert'),
289 widget
.proxy
.revert
),
291 qtutils
.add_action(widget
, N_('Launch Diff Tool'),
292 widget
.proxy
.show_diff
, hotkeys
.DIFF
),
294 qtutils
.add_action(widget
, N_('Launch Directory Diff Tool'),
295 widget
.proxy
.show_dir_diff
, hotkeys
.DIFF_SECONDARY
),
297 qtutils
.add_action(widget
, N_('Checkout Detached HEAD'),
298 widget
.proxy
.checkout_detached
),
300 qtutils
.add_action(widget
, N_('Reset Branch Head'),
301 widget
.proxy
.reset_branch_head
),
303 qtutils
.add_action(widget
, N_('Reset Worktree'),
304 widget
.proxy
.reset_worktree
),
306 qtutils
.add_action(widget
, N_('Reset Merge'),
307 widget
.proxy
.reset_merge
),
309 qtutils
.add_action(widget
, N_('Reset Soft'),
310 widget
.proxy
.reset_soft
),
312 qtutils
.add_action(widget
, N_('Reset Hard'),
313 widget
.proxy
.reset_hard
),
315 qtutils
.add_action(widget
, N_('Grab File...'),
316 widget
.proxy
.save_blob_dialog
),
318 qtutils
.add_action(widget
, N_('Copy SHA-1'),
319 widget
.proxy
.copy_to_clipboard
,
324 class CommitTreeWidgetItem(QtWidgets
.QTreeWidgetItem
):
326 def __init__(self
, commit
, parent
=None):
327 QtWidgets
.QTreeWidgetItem
.__init
__(self
, parent
)
329 self
.setText(0, commit
.summary
)
330 self
.setText(1, commit
.author
)
331 self
.setText(2, commit
.authdate
)
334 class CommitTreeWidget(standard
.TreeWidget
, ViewerMixin
):
336 diff_commits
= Signal(object, object)
337 zoom_to_fit
= Signal()
339 def __init__(self
, context
, notifier
, parent
):
340 standard
.TreeWidget
.__init
__(self
, parent
)
341 ViewerMixin
.__init
__(self
)
343 self
.setSelectionMode(self
.ExtendedSelection
)
344 self
.setHeaderLabels([N_('Summary'), N_('Author'), N_('Date, Time')])
346 self
.context
= context
348 self
.menu_actions
= None
349 self
.notifier
= notifier
350 self
.selecting
= False
352 self
._adjust
_columns
= False
354 self
.action_up
= qtutils
.add_action(
355 self
, N_('Go Up'), self
.go_up
, hotkeys
.MOVE_UP
)
357 self
.action_down
= qtutils
.add_action(
358 self
, N_('Go Down'), self
.go_down
, hotkeys
.MOVE_DOWN
)
360 self
.zoom_to_fit_action
= qtutils
.add_action(
361 self
, N_('Zoom to Fit'), self
.zoom_to_fit
.emit
, hotkeys
.FIT
)
363 notifier
.add_observer(diff
.COMMITS_SELECTED
, self
.commits_selected
)
365 self
.itemSelectionChanged
.connect(self
.selection_changed
)
367 def export_state(self
):
368 """Export the widget's state"""
369 # The base class method is intentionally overridden because we only
370 # care about the details below for this subwidget.
372 state
['column_widths'] = self
.column_widths()
375 def apply_state(self
, state
):
376 """Apply the exported widget state"""
378 column_widths
= state
['column_widths']
379 except (KeyError, ValueError):
382 self
.set_column_widths(column_widths
)
384 # Defer showing the columns until we are shown, and our true width
385 # is known. Calling adjust_columns() here ends up with the wrong
386 # answer because we have not yet been parented to the layout.
387 # We set this flag that we process once during our initial
389 self
._adjust
_columns
= True
393 def showEvent(self
, event
):
394 """Override QWidget::showEvent() to size columns when we are shown"""
395 if self
._adjust
_columns
:
396 self
._adjust
_columns
= False
398 two_thirds
= (width
* 2) // 3
399 one_sixth
= width
// 6
401 self
.setColumnWidth(0, two_thirds
)
402 self
.setColumnWidth(1, one_sixth
)
403 self
.setColumnWidth(2, one_sixth
)
404 return standard
.TreeWidget
.showEvent(self
, event
)
408 self
.goto(self
.itemAbove
)
411 self
.goto(self
.itemBelow
)
413 def goto(self
, finder
):
414 items
= self
.selected_items()
415 item
= items
[0] if items
else None
420 self
.select([found
.commit
.oid
])
422 def selected_commit_range(self
):
423 selected_items
= self
.selected_items()
424 if not selected_items
:
426 return selected_items
[-1].commit
.oid
, selected_items
[0].commit
.oid
428 def set_selecting(self
, selecting
):
429 self
.selecting
= selecting
431 def selection_changed(self
):
432 items
= self
.selected_items()
435 self
.set_selecting(True)
436 self
.notifier
.notify_observers(diff
.COMMITS_SELECTED
,
437 [i
.commit
for i
in items
])
438 self
.set_selecting(False)
440 def commits_selected(self
, commits
):
443 with qtutils
.BlockSignals(self
):
444 self
.select([commit
.oid
for commit
in commits
])
446 def select(self
, oids
):
449 self
.clearSelection()
452 item
= self
.oidmap
[oid
]
455 self
.scrollToItem(item
)
456 item
.setSelected(True)
459 QtWidgets
.QTreeWidget
.clear(self
)
463 def add_commits(self
, commits
):
464 self
.commits
.extend(commits
)
466 for c
in reversed(commits
):
467 item
= CommitTreeWidgetItem(c
)
469 self
.oidmap
[c
.oid
] = item
471 self
.oidmap
[tag
] = item
472 self
.insertTopLevelItems(0, items
)
474 def create_patch(self
):
475 items
= self
.selectedItems()
478 context
= self
.context
479 oids
= [item
.commit
.oid
for item
in reversed(items
)]
480 all_oids
= [c
.oid
for c
in self
.commits
]
481 cmds
.do(cmds
.FormatPatch
, context
, oids
, all_oids
)
484 def contextMenuEvent(self
, event
):
485 self
.context_menu_event(event
)
487 def mousePressEvent(self
, event
):
488 if event
.button() == Qt
.RightButton
:
491 QtWidgets
.QTreeWidget
.mousePressEvent(self
, event
)
494 class GitDAG(standard
.MainWindow
):
495 """The git-dag widget."""
498 def __init__(self
, context
, params
, parent
=None, settings
=None):
499 super(GitDAG
, self
).__init
__(parent
)
501 self
.setMinimumSize(420, 420)
503 # change when widgets are added/removed
504 self
.widget_version
= 2
505 self
.context
= context
507 self
.model
= context
.model
508 self
.settings
= settings
511 self
.commit_list
= []
513 self
.old_refs
= set()
516 self
.force_refresh
= False
519 self
.revtext
= completion
.GitLogLineEdit(context
)
520 self
.maxresults
= standard
.SpinBox()
522 self
.zoom_out
= qtutils
.create_action_button(
523 tooltip
=N_('Zoom Out'), icon
=icons
.zoom_out())
525 self
.zoom_in
= qtutils
.create_action_button(
526 tooltip
=N_('Zoom In'), icon
=icons
.zoom_in())
528 self
.zoom_to_fit
= qtutils
.create_action_button(
529 tooltip
=N_('Zoom to Fit'), icon
=icons
.zoom_fit_best())
531 self
.notifier
= notifier
= observable
.Observable()
532 self
.notifier
.refs_updated
= refs_updated
= 'refs_updated'
533 self
.notifier
.add_observer(refs_updated
, self
.display
)
534 self
.notifier
.add_observer(filelist
.HISTORIES_SELECTED
,
535 self
.histories_selected
)
536 self
.notifier
.add_observer(filelist
.DIFFTOOL_SELECTED
,
537 self
.difftool_selected
)
538 self
.notifier
.add_observer(diff
.COMMITS_SELECTED
, self
.commits_selected
)
540 self
.treewidget
= CommitTreeWidget(context
, notifier
, self
)
541 self
.diffwidget
= diff
.DiffWidget(context
, notifier
, self
,
543 self
.filewidget
= filelist
.FileWidget(context
, notifier
, self
)
544 self
.graphview
= GraphView(context
, notifier
, self
)
546 self
.proxy
= FocusRedirectProxy(self
.treewidget
,
550 self
.viewer_actions
= actions
= viewer_actions(self
)
551 self
.treewidget
.menu_actions
= actions
552 self
.graphview
.menu_actions
= actions
554 self
.controls_layout
= qtutils
.hbox(defs
.no_margin
, defs
.spacing
,
555 self
.revtext
, self
.maxresults
)
557 self
.controls_widget
= QtWidgets
.QWidget()
558 self
.controls_widget
.setLayout(self
.controls_layout
)
560 self
.log_dock
= qtutils
.create_dock(N_('Log'), self
, stretch
=False)
561 self
.log_dock
.setWidget(self
.treewidget
)
562 log_dock_titlebar
= self
.log_dock
.titleBarWidget()
563 log_dock_titlebar
.add_corner_widget(self
.controls_widget
)
565 self
.file_dock
= qtutils
.create_dock(N_('Files'), self
)
566 self
.file_dock
.setWidget(self
.filewidget
)
568 self
.diff_dock
= qtutils
.create_dock(N_('Diff'), self
)
569 self
.diff_dock
.setWidget(self
.diffwidget
)
571 self
.graph_controls_layout
= qtutils
.hbox(
572 defs
.no_margin
, defs
.button_spacing
,
573 self
.zoom_out
, self
.zoom_in
, self
.zoom_to_fit
, defs
.spacing
)
575 self
.graph_controls_widget
= QtWidgets
.QWidget()
576 self
.graph_controls_widget
.setLayout(self
.graph_controls_layout
)
578 self
.graphview_dock
= qtutils
.create_dock(N_('Graph'), self
)
579 self
.graphview_dock
.setWidget(self
.graphview
)
580 graph_titlebar
= self
.graphview_dock
.titleBarWidget()
581 graph_titlebar
.add_corner_widget(self
.graph_controls_widget
)
583 self
.lock_layout_action
= qtutils
.add_action_bool(
584 self
, N_('Lock Layout'), self
.set_lock_layout
, False)
586 self
.refresh_action
= qtutils
.add_action(
587 self
, N_('Refresh'), self
.refresh
, hotkeys
.REFRESH
)
589 # Create the application menu
590 self
.menubar
= QtWidgets
.QMenuBar(self
)
591 self
.setMenuBar(self
.menubar
)
594 self
.view_menu
= qtutils
.add_menu(N_('View'), self
.menubar
)
595 self
.view_menu
.addAction(self
.refresh_action
)
596 self
.view_menu
.addAction(self
.log_dock
.toggleViewAction())
597 self
.view_menu
.addAction(self
.graphview_dock
.toggleViewAction())
598 self
.view_menu
.addAction(self
.diff_dock
.toggleViewAction())
599 self
.view_menu
.addAction(self
.file_dock
.toggleViewAction())
600 self
.view_menu
.addSeparator()
601 self
.view_menu
.addAction(self
.lock_layout_action
)
603 left
= Qt
.LeftDockWidgetArea
604 right
= Qt
.RightDockWidgetArea
605 self
.addDockWidget(left
, self
.log_dock
)
606 self
.addDockWidget(left
, self
.diff_dock
)
607 self
.addDockWidget(right
, self
.graphview_dock
)
608 self
.addDockWidget(right
, self
.file_dock
)
610 # Also re-loads dag.* from the saved state
611 self
.init_state(settings
, self
.resize_to_desktop
)
613 qtutils
.connect_button(self
.zoom_out
, self
.graphview
.zoom_out
)
614 qtutils
.connect_button(self
.zoom_in
, self
.graphview
.zoom_in
)
615 qtutils
.connect_button(self
.zoom_to_fit
,
616 self
.graphview
.zoom_to_fit
)
618 self
.treewidget
.zoom_to_fit
.connect(self
.graphview
.zoom_to_fit
)
619 self
.treewidget
.diff_commits
.connect(self
.diff_commits
)
620 self
.graphview
.diff_commits
.connect(self
.diff_commits
)
621 self
.filewidget
.grab_file
.connect(self
.grab_file
)
623 self
.maxresults
.editingFinished
.connect(self
.display
)
625 self
.revtext
.textChanged
.connect(self
.text_changed
)
626 self
.revtext
.activated
.connect(self
.display
)
627 self
.revtext
.enter
.connect(self
.display
)
628 self
.revtext
.down
.connect(self
.focus_tree
)
630 # The model is updated in another thread so use
631 # signals/slots to bring control back to the main GUI thread
632 self
.model
.add_observer(self
.model
.message_updated
, self
.updated
.emit
)
633 self
.updated
.connect(self
.model_updated
, type=Qt
.QueuedConnection
)
635 qtutils
.add_action(self
, 'Focus', self
.focus_input
, hotkeys
.FOCUS
)
636 qtutils
.add_close_action(self
)
638 self
.set_params(params
)
640 def set_params(self
, params
):
641 context
= self
.context
644 # Update fields affected by model
645 self
.revtext
.setText(params
.ref
)
646 self
.maxresults
.setValue(params
.count
)
647 self
.update_window_title()
649 if self
.thread
is not None:
652 self
.thread
= ReaderThread(context
, params
, self
)
655 thread
.begin
.connect(self
.thread_begin
, type=Qt
.QueuedConnection
)
656 thread
.status
.connect(self
.thread_status
, type=Qt
.QueuedConnection
)
657 thread
.add
.connect(self
.add_commits
, type=Qt
.QueuedConnection
)
658 thread
.end
.connect(self
.thread_end
, type=Qt
.QueuedConnection
)
660 def focus_input(self
):
661 self
.revtext
.setFocus()
663 def focus_tree(self
):
664 self
.treewidget
.setFocus()
666 def text_changed(self
, txt
):
667 self
.params
.ref
= txt
668 self
.update_window_title()
670 def update_window_title(self
):
671 project
= self
.model
.project
673 self
.setWindowTitle(N_('%(project)s: %(ref)s - DAG')
674 % dict(project
=project
, ref
=self
.params
.ref
))
676 self
.setWindowTitle(project
+ N_(' - DAG'))
678 def export_state(self
):
679 state
= standard
.MainWindow
.export_state(self
)
680 state
['count'] = self
.params
.count
681 state
['log'] = self
.treewidget
.export_state()
684 def apply_state(self
, state
):
685 result
= standard
.MainWindow
.apply_state(self
, state
)
687 count
= state
['count']
688 if self
.params
.overridden('count'):
689 count
= self
.params
.count
690 except (KeyError, TypeError, ValueError, AttributeError):
691 count
= self
.params
.count
693 self
.params
.set_count(count
)
694 self
.lock_layout_action
.setChecked(state
.get('lock_layout', False))
697 log_state
= state
['log']
698 except (KeyError, ValueError):
701 self
.treewidget
.apply_state(log_state
)
705 def model_updated(self
):
709 """Unconditionally refresh the DAG"""
710 # self.force_refresh triggers an Unconditional redraw
711 self
.force_refresh
= True
712 cmds
.do(cmds
.Refresh
, self
.context
)
713 self
.force_refresh
= False
716 """Update the view when the Git refs change"""
717 ref
= get(self
.revtext
)
718 count
= get(self
.maxresults
)
719 context
= self
.context
721 # The DAG tries to avoid updating when the object IDs have not
722 # changed. Without doing this the DAG constantly redraws itself
723 # whenever inotify sends update events, which hurts usability.
725 # To minimize redraws we leverage `git rev-parse`. The strategy is to
726 # use `git rev-parse` on the input line, which converts each argument
727 # into object IDs. From there it's a simple matter of detecting when
728 # the object IDs changed.
730 # In addition to object IDs, we also need to know when the set of
731 # named references (branches, tags) changes so that an update is
732 # triggered when new branches and tags are created.
733 refs
= set(model
.local_branches
+ model
.remote_branches
+ model
.tags
)
734 argv
= utils
.shell_split(ref
or 'HEAD')
735 oids
= gitcmds
.parse_refs(context
, argv
)
736 update
= (self
.force_refresh
737 or count
!= self
.old_count
738 or oids
!= self
.old_oids
739 or refs
!= self
.old_refs
)
742 self
.params
.set_ref(ref
)
743 self
.params
.set_count(count
)
747 self
.old_count
= count
750 def commits_selected(self
, commits
):
752 self
.selection
= commits
756 self
.commit_list
= []
757 self
.graphview
.clear()
758 self
.treewidget
.clear()
760 def add_commits(self
, commits
):
761 self
.commit_list
.extend(commits
)
762 # Keep track of commits
763 for commit_obj
in commits
:
764 self
.commits
[commit_obj
.oid
] = commit_obj
765 for tag
in commit_obj
.tags
:
766 self
.commits
[tag
] = commit_obj
767 self
.graphview
.add_commits(commits
)
768 self
.treewidget
.add_commits(commits
)
770 def thread_begin(self
):
773 def thread_end(self
):
774 self
.restore_selection()
776 def thread_status(self
, successful
):
777 self
.revtext
.hint
.set_error(not successful
)
779 def restore_selection(self
):
780 selection
= self
.selection
782 commit_obj
= self
.commit_list
[-1]
784 # No commits, exist, early-out
787 new_commits
= [self
.commits
.get(s
.oid
, None) for s
in selection
]
788 new_commits
= [c
for c
in new_commits
if c
is not None]
790 # The old selection exists in the new state
791 self
.notifier
.notify_observers(diff
.COMMITS_SELECTED
, new_commits
)
793 # The old selection is now empty. Select the top-most commit
794 self
.notifier
.notify_observers(diff
.COMMITS_SELECTED
, [commit_obj
])
796 self
.graphview
.set_initial_view()
798 def diff_commits(self
, a
, b
):
799 paths
= self
.params
.paths()
801 cmds
.difftool_launch(self
.context
, left
=a
, right
=b
, paths
=paths
)
803 difftool
.diff_commits(self
.context
, self
, a
, b
)
806 def closeEvent(self
, event
):
807 self
.revtext
.close_popup()
809 standard
.MainWindow
.closeEvent(self
, event
)
811 def histories_selected(self
, histories
):
812 argv
= [self
.model
.currentbranch
, '--']
813 argv
.extend(histories
)
814 text
= core
.list2cmdline(argv
)
815 self
.revtext
.setText(text
)
818 def difftool_selected(self
, files
):
819 bottom
, top
= self
.treewidget
.selected_commit_range()
822 cmds
.difftool_launch(self
.context
, left
=bottom
, left_take_parent
=True,
823 right
=top
, paths
=files
)
825 def grab_file(self
, filename
):
826 """Save the selected file from the filelist widget"""
827 oid
= self
.treewidget
.selected_oid()
828 model
= browse
.BrowseModel(oid
, filename
=filename
)
829 browse
.save_path(self
.context
, filename
, model
)
832 class ReaderThread(QtCore
.QThread
):
836 status
= Signal(object)
838 def __init__(self
, context
, params
, parent
):
839 QtCore
.QThread
.__init
__(self
, parent
)
840 self
.context
= context
844 self
._mutex
= QtCore
.QMutex()
845 self
._condition
= QtCore
.QWaitCondition()
848 context
= self
.context
849 repo
= dag
.RepoReader(context
, self
.params
)
856 self
._condition
.wait(self
._mutex
)
862 if len(commits
) >= 512:
863 self
.add
.emit(commits
)
866 self
.status
.emit(repo
.returncode
== 0)
868 self
.add
.emit(commits
)
874 QtCore
.QThread
.start(self
)
885 self
._condition
.wakeOne()
898 font
= cls
._label
_font
900 font
= cls
._label
_font
= QtWidgets
.QApplication
.font()
905 class Edge(QtWidgets
.QGraphicsItem
):
906 item_type
= QtWidgets
.QGraphicsItem
.UserType
+ 1
908 def __init__(self
, source
, dest
):
910 QtWidgets
.QGraphicsItem
.__init
__(self
)
912 self
.setAcceptedMouseButtons(Qt
.NoButton
)
915 self
.commit
= source
.commit
918 self
.recompute_bound()
920 self
.path_valid
= False
922 # Choose a new color for new branch edges
923 if self
.source
.x() < self
.dest
.x():
924 color
= EdgeColor
.cycle()
926 elif self
.source
.x() != self
.dest
.x():
927 color
= EdgeColor
.current()
930 color
= EdgeColor
.current()
933 self
.pen
= QtGui
.QPen(color
, 4.0, line
, Qt
.SquareCap
, Qt
.RoundJoin
)
935 def recompute_bound(self
):
936 dest_pt
= Commit
.item_bbox
.center()
938 self
.source_pt
= self
.mapFromItem(self
.source
, dest_pt
)
939 self
.dest_pt
= self
.mapFromItem(self
.dest
, dest_pt
)
940 self
.line
= QtCore
.QLineF(self
.source_pt
, self
.dest_pt
)
942 width
= self
.dest_pt
.x() - self
.source_pt
.x()
943 height
= self
.dest_pt
.y() - self
.source_pt
.y()
944 rect
= QtCore
.QRectF(self
.source_pt
, QtCore
.QSizeF(width
, height
))
945 self
.bound
= rect
.normalized()
947 def commits_were_invalidated(self
):
948 self
.recompute_bound()
949 self
.prepareGeometryChange()
950 # The path should not be recomputed immediately because just small part
951 # of DAG is actually shown at same time. It will be recomputed on
952 # demand in course of 'paint' method.
953 self
.path_valid
= False
954 # Hence, just queue redrawing.
959 return self
.item_type
961 def boundingRect(self
):
964 def recompute_path(self
):
965 QRectF
= QtCore
.QRectF
966 QPointF
= QtCore
.QPointF
971 path
= QtGui
.QPainterPath()
973 if self
.source
.x() == self
.dest
.x():
974 path
.moveTo(self
.source
.x(), self
.source
.y())
975 path
.lineTo(self
.dest
.x(), self
.dest
.y())
977 # Define points starting from source
978 point1
= QPointF(self
.source
.x(), self
.source
.y())
979 point2
= QPointF(point1
.x(), point1
.y() - connector_length
)
980 point3
= QPointF(point2
.x() + arc_rect
, point2
.y() - arc_rect
)
982 # Define points starting from dest
983 point4
= QPointF(self
.dest
.x(), self
.dest
.y())
984 point5
= QPointF(point4
.x(), point3
.y() - arc_rect
)
985 point6
= QPointF(point5
.x() - arc_rect
, point5
.y() + arc_rect
)
987 start_angle_arc1
= 180
989 start_angle_arc2
= 90
990 span_angle_arc2
= -90
992 # If the dest is at the left of the source, then we
993 # need to reverse some values
994 if self
.source
.x() > self
.dest
.x():
995 point5
= QPointF(point4
.x(), point4
.y() + connector_length
)
996 point6
= QPointF(point5
.x() + arc_rect
, point5
.y() + arc_rect
)
997 point3
= QPointF(self
.source
.x() - arc_rect
, point6
.y())
998 point2
= QPointF(self
.source
.x(), point3
.y() + arc_rect
)
1000 span_angle_arc1
= 90
1004 path
.arcTo(QRectF(point2
, point3
),
1005 start_angle_arc1
, span_angle_arc1
)
1007 path
.arcTo(QRectF(point6
, point5
),
1008 start_angle_arc2
, span_angle_arc2
)
1012 self
.path_valid
= True
1014 def paint(self
, painter
, _option
, _widget
):
1015 if not self
.path_valid
:
1016 self
.recompute_path()
1017 painter
.setPen(self
.pen
)
1018 painter
.drawPath(self
.path
)
1021 class EdgeColor(object):
1022 """An edge color factory"""
1024 current_color_index
= 0
1026 QtGui
.QColor(Qt
.red
),
1027 QtGui
.QColor(Qt
.green
),
1028 QtGui
.QColor(Qt
.blue
),
1029 QtGui
.QColor(Qt
.black
),
1030 QtGui
.QColor(Qt
.darkRed
),
1031 QtGui
.QColor(Qt
.darkGreen
),
1032 QtGui
.QColor(Qt
.darkBlue
),
1033 QtGui
.QColor(Qt
.cyan
),
1034 QtGui
.QColor(Qt
.magenta
),
1035 # Orange; Qt.yellow is too low-contrast
1036 qtutils
.rgba(0xff, 0x66, 0x00),
1037 QtGui
.QColor(Qt
.gray
),
1038 QtGui
.QColor(Qt
.darkCyan
),
1039 QtGui
.QColor(Qt
.darkMagenta
),
1040 QtGui
.QColor(Qt
.darkYellow
),
1041 QtGui
.QColor(Qt
.darkGray
),
1046 cls
.current_color_index
+= 1
1047 cls
.current_color_index
%= len(cls
.colors
)
1048 color
= cls
.colors
[cls
.current_color_index
]
1054 return cls
.colors
[cls
.current_color_index
]
1058 cls
.current_color_index
= 0
1061 class Commit(QtWidgets
.QGraphicsItem
):
1062 item_type
= QtWidgets
.QGraphicsItem
.UserType
+ 2
1063 commit_radius
= 12.0
1066 item_shape
= QtGui
.QPainterPath()
1067 item_shape
.addRect(commit_radius
/-2.0,
1069 commit_radius
, commit_radius
)
1070 item_bbox
= item_shape
.boundingRect()
1072 inner_rect
= QtGui
.QPainterPath()
1073 inner_rect
.addRect(commit_radius
/-2.0 + 2.0,
1074 commit_radius
/-2.0 + 2.0,
1075 commit_radius
- 4.0,
1076 commit_radius
- 4.0)
1077 inner_rect
= inner_rect
.boundingRect()
1079 commit_color
= QtGui
.QColor(Qt
.white
)
1080 outline_color
= commit_color
.darker()
1081 merge_color
= QtGui
.QColor(Qt
.lightGray
)
1083 commit_selected_color
= QtGui
.QColor(Qt
.green
)
1084 selected_outline_color
= commit_selected_color
.darker()
1086 commit_pen
= QtGui
.QPen()
1087 commit_pen
.setWidth(1.0)
1088 commit_pen
.setColor(outline_color
)
1090 def __init__(self
, commit
,
1092 selectable
=QtWidgets
.QGraphicsItem
.ItemIsSelectable
,
1093 cursor
=Qt
.PointingHandCursor
,
1094 xpos
=commit_radius
/2.0 + 1.0,
1095 cached_commit_color
=commit_color
,
1096 cached_merge_color
=merge_color
):
1098 QtWidgets
.QGraphicsItem
.__init
__(self
)
1100 self
.commit
= commit
1101 self
.notifier
= notifier
1102 self
.selected
= False
1105 self
.setFlag(selectable
)
1106 self
.setCursor(cursor
)
1107 self
.setToolTip(commit
.oid
[:12] + ': ' + commit
.summary
)
1110 self
.label
= label
= Label(commit
)
1111 label
.setParentItem(self
)
1112 label
.setPos(xpos
+ 1, -self
.commit_radius
/2.0)
1116 if len(commit
.parents
) > 1:
1117 self
.brush
= cached_merge_color
1119 self
.brush
= cached_commit_color
1121 self
.pressed
= False
1122 self
.dragged
= False
1126 def blockSignals(self
, blocked
):
1127 self
.notifier
.notification_enabled
= not blocked
1129 def itemChange(self
, change
, value
):
1130 if change
== QtWidgets
.QGraphicsItem
.ItemSelectedHasChanged
:
1131 # Broadcast selection to other widgets
1132 selected_items
= self
.scene().selectedItems()
1133 commits
= [item
.commit
for item
in selected_items
]
1134 self
.scene().parent().set_selecting(True)
1135 self
.notifier
.notify_observers(diff
.COMMITS_SELECTED
, commits
)
1136 self
.scene().parent().set_selecting(False)
1138 # Cache the pen for use in paint()
1140 self
.brush
= self
.commit_selected_color
1141 color
= self
.selected_outline_color
1143 if len(self
.commit
.parents
) > 1:
1144 self
.brush
= self
.merge_color
1146 self
.brush
= self
.commit_color
1147 color
= self
.outline_color
1148 commit_pen
= QtGui
.QPen()
1149 commit_pen
.setWidth(1.0)
1150 commit_pen
.setColor(color
)
1151 self
.commit_pen
= commit_pen
1153 return QtWidgets
.QGraphicsItem
.itemChange(self
, change
, value
)
1156 return self
.item_type
1158 def boundingRect(self
):
1159 return self
.item_bbox
1162 return self
.item_shape
1164 def paint(self
, painter
, option
, _widget
):
1166 # Do not draw outside the exposed rect
1167 painter
.setClipRect(option
.exposedRect
)
1170 painter
.setPen(self
.commit_pen
)
1171 painter
.setBrush(self
.brush
)
1172 painter
.drawEllipse(self
.inner_rect
)
1174 def mousePressEvent(self
, event
):
1175 QtWidgets
.QGraphicsItem
.mousePressEvent(self
, event
)
1177 self
.selected
= self
.isSelected()
1179 def mouseMoveEvent(self
, event
):
1182 QtWidgets
.QGraphicsItem
.mouseMoveEvent(self
, event
)
1184 def mouseReleaseEvent(self
, event
):
1185 QtWidgets
.QGraphicsItem
.mouseReleaseEvent(self
, event
)
1186 if (not self
.dragged
and
1188 event
.button() == Qt
.LeftButton
):
1190 self
.pressed
= False
1191 self
.dragged
= False
1194 class Label(QtWidgets
.QGraphicsItem
):
1196 item_type
= QtWidgets
.QGraphicsItem
.UserType
+ 3
1198 head_color
= QtGui
.QColor(Qt
.green
)
1199 other_color
= QtGui
.QColor(Qt
.white
)
1200 remote_color
= QtGui
.QColor(Qt
.yellow
)
1202 head_pen
= QtGui
.QPen()
1203 head_pen
.setColor(head_color
.darker().darker())
1204 head_pen
.setWidth(1.0)
1206 text_pen
= QtGui
.QPen()
1207 text_pen
.setColor(QtGui
.QColor(Qt
.darkGray
))
1208 text_pen
.setWidth(1.0)
1211 head_color
.setAlpha(alpha
)
1212 other_color
.setAlpha(alpha
)
1213 remote_color
.setAlpha(alpha
)
1219 def __init__(self
, commit
):
1220 QtWidgets
.QGraphicsItem
.__init
__(self
)
1222 self
.commit
= commit
1225 return self
.item_type
1227 def boundingRect(self
, cache
=Cache
):
1228 QPainterPath
= QtGui
.QPainterPath
1229 QRectF
= QtCore
.QRectF
1234 spacing
= self
.item_spacing
1235 border
= self
.border
+ self
.text_offset
# text offset=1 in paint()
1237 font
= cache
.label_font()
1238 item_shape
= QPainterPath()
1240 base_rect
= QRectF(0, 0, width
, height
)
1241 base_rect
= base_rect
.adjusted(-border
, -border
, border
, border
)
1242 item_shape
.addRect(base_rect
)
1244 for tag
in self
.commit
.tags
:
1245 text_shape
= QPainterPath()
1246 text_shape
.addText(current_width
, 0, font
, tag
)
1247 text_rect
= text_shape
.boundingRect()
1248 box_rect
= text_rect
.adjusted(-border
, -border
, border
, border
)
1249 item_shape
.addRect(box_rect
)
1250 current_width
= item_shape
.boundingRect().width() + spacing
1252 return item_shape
.boundingRect()
1254 def paint(self
, painter
, _option
, _widget
, cache
=Cache
):
1255 # Draw tags and branches
1256 font
= cache
.label_font()
1257 painter
.setFont(font
)
1260 border
= self
.border
1261 offset
= self
.text_offset
1262 spacing
= self
.item_spacing
1263 QRectF
= QtCore
.QRectF
1266 remotes_prefix
= 'remotes/'
1267 tags_prefix
= 'tags/'
1268 heads_prefix
= 'heads/'
1269 remotes_len
= len(remotes_prefix
)
1270 tags_len
= len(tags_prefix
)
1271 heads_len
= len(heads_prefix
)
1273 for tag
in self
.commit
.tags
:
1275 painter
.setPen(self
.text_pen
)
1276 painter
.setBrush(self
.remote_color
)
1277 elif tag
.startswith(remotes_prefix
):
1278 tag
= tag
[remotes_len
:]
1279 painter
.setPen(self
.text_pen
)
1280 painter
.setBrush(self
.other_color
)
1281 elif tag
.startswith(tags_prefix
):
1282 tag
= tag
[tags_len
:]
1283 painter
.setPen(self
.text_pen
)
1284 painter
.setBrush(self
.remote_color
)
1285 elif tag
.startswith(heads_prefix
):
1286 tag
= tag
[heads_len
:]
1287 painter
.setPen(self
.head_pen
)
1288 painter
.setBrush(self
.head_color
)
1290 painter
.setPen(self
.text_pen
)
1291 painter
.setBrush(self
.other_color
)
1293 text_rect
= painter
.boundingRect(
1294 QRectF(current_width
, 0, 0, 0), Qt
.TextSingleLine
, tag
)
1295 box_rect
= text_rect
.adjusted(-offset
, -offset
, offset
, offset
)
1297 painter
.drawRoundedRect(box_rect
, border
, border
)
1298 painter
.drawText(text_rect
, Qt
.TextSingleLine
, tag
)
1299 current_width
+= text_rect
.width() + spacing
1302 class GraphView(QtWidgets
.QGraphicsView
, ViewerMixin
):
1304 diff_commits
= Signal(object, object)
1306 x_adjust
= int(Commit
.commit_radius
*4/3)
1307 y_adjust
= int(Commit
.commit_radius
*4/3)
1312 def __init__(self
, context
, notifier
, parent
):
1313 QtWidgets
.QGraphicsView
.__init
__(self
, parent
)
1314 ViewerMixin
.__init
__(self
)
1316 highlight
= self
.palette().color(QtGui
.QPalette
.Highlight
)
1317 Commit
.commit_selected_color
= highlight
1318 Commit
.selected_outline_color
= highlight
.darker()
1320 self
.context
= context
1322 self
.selection_list
= []
1323 self
.menu_actions
= None
1324 self
.notifier
= notifier
1327 self
.mouse_start
= [0, 0]
1328 self
.saved_matrix
= self
.transform()
1332 self
.tagged_cells
= set()
1336 self
.x_offsets
= collections
.defaultdict(lambda: self
.x_min
)
1338 self
.is_panning
= False
1339 self
.pressed
= False
1340 self
.selecting
= False
1341 self
.last_mouse
= [0, 0]
1343 self
.setDragMode(self
.RubberBandDrag
)
1345 scene
= QtWidgets
.QGraphicsScene(self
)
1346 scene
.setItemIndexMethod(QtWidgets
.QGraphicsScene
.NoIndex
)
1347 self
.setScene(scene
)
1349 self
.setRenderHint(QtGui
.QPainter
.Antialiasing
)
1350 self
.setViewportUpdateMode(self
.BoundingRectViewportUpdate
)
1351 self
.setCacheMode(QtWidgets
.QGraphicsView
.CacheBackground
)
1352 self
.setTransformationAnchor(QtWidgets
.QGraphicsView
.AnchorUnderMouse
)
1353 self
.setResizeAnchor(QtWidgets
.QGraphicsView
.NoAnchor
)
1354 self
.setBackgroundBrush(QtGui
.QColor(Qt
.white
))
1356 qtutils
.add_action(self
, N_('Zoom In'), self
.zoom_in
,
1357 hotkeys
.ZOOM_IN
, hotkeys
.ZOOM_IN_SECONDARY
)
1359 qtutils
.add_action(self
, N_('Zoom Out'), self
.zoom_out
,
1362 qtutils
.add_action(self
, N_('Zoom to Fit'),
1363 self
.zoom_to_fit
, hotkeys
.FIT
)
1365 qtutils
.add_action(self
, N_('Select Parent'),
1366 self
._select
_parent
, hotkeys
.MOVE_DOWN_TERTIARY
)
1368 qtutils
.add_action(self
, N_('Select Oldest Parent'),
1369 self
._select
_oldest
_parent
, hotkeys
.MOVE_DOWN
)
1371 qtutils
.add_action(self
, N_('Select Child'),
1372 self
._select
_child
, hotkeys
.MOVE_UP_TERTIARY
)
1374 qtutils
.add_action(self
, N_('Select Newest Child'),
1375 self
._select
_newest
_child
, hotkeys
.MOVE_UP
)
1377 notifier
.add_observer(diff
.COMMITS_SELECTED
, self
.commits_selected
)
1381 self
.scene().clear()
1382 self
.selection_list
= []
1384 self
.x_offsets
.clear()
1388 # ViewerMixin interface
1389 def selected_items(self
):
1390 """Return the currently selected items"""
1391 return self
.scene().selectedItems()
1394 self
.scale_view(1.5)
1397 self
.scale_view(1.0/1.5)
1399 def commits_selected(self
, commits
):
1402 self
.select([commit
.oid
for commit
in commits
])
1404 def select(self
, oids
):
1405 """Select the item for the oids"""
1406 self
.scene().clearSelection()
1409 item
= self
.items
[oid
]
1412 item
.blockSignals(True)
1413 item
.setSelected(True)
1414 item
.blockSignals(False)
1415 item_rect
= item
.sceneTransform().mapRect(item
.boundingRect())
1416 self
.ensureVisible(item_rect
)
1418 def _get_item_by_generation(self
, commits
, criteria_fn
):
1419 """Return the item for the commit matching criteria"""
1423 for commit
in commits
:
1424 if (generation
is None or
1425 criteria_fn(generation
, commit
.generation
)):
1427 generation
= commit
.generation
1429 return self
.items
[oid
]
1433 def _oldest_item(self
, commits
):
1434 """Return the item for the commit with the oldest generation number"""
1435 return self
._get
_item
_by
_generation
(commits
, lambda a
, b
: a
> b
)
1437 def _newest_item(self
, commits
):
1438 """Return the item for the commit with the newest generation number"""
1439 return self
._get
_item
_by
_generation
(commits
, lambda a
, b
: a
< b
)
1441 def create_patch(self
):
1442 items
= self
.selected_items()
1445 context
= self
.context
1446 selected_commits
= sort_by_generation([n
.commit
for n
in items
])
1447 oids
= [c
.oid
for c
in selected_commits
]
1448 all_oids
= [c
.oid
for c
in self
.commits
]
1449 cmds
.do(cmds
.FormatPatch
, context
, oids
, all_oids
)
1451 def _select_parent(self
):
1452 """Select the parent with the newest generation number"""
1453 selected_item
= self
.selected_item()
1454 if selected_item
is None:
1456 parent_item
= self
._newest
_item
(selected_item
.commit
.parents
)
1457 if parent_item
is None:
1459 selected_item
.setSelected(False)
1460 parent_item
.setSelected(True)
1462 parent_item
.mapRectToScene(parent_item
.boundingRect()))
1464 def _select_oldest_parent(self
):
1465 """Select the parent with the oldest generation number"""
1466 selected_item
= self
.selected_item()
1467 if selected_item
is None:
1469 parent_item
= self
._oldest
_item
(selected_item
.commit
.parents
)
1470 if parent_item
is None:
1472 selected_item
.setSelected(False)
1473 parent_item
.setSelected(True)
1474 scene_rect
= parent_item
.mapRectToScene(parent_item
.boundingRect())
1475 self
.ensureVisible(scene_rect
)
1477 def _select_child(self
):
1478 """Select the child with the oldest generation number"""
1479 selected_item
= self
.selected_item()
1480 if selected_item
is None:
1482 child_item
= self
._oldest
_item
(selected_item
.commit
.children
)
1483 if child_item
is None:
1485 selected_item
.setSelected(False)
1486 child_item
.setSelected(True)
1487 scene_rect
= child_item
.mapRectToScene(child_item
.boundingRect())
1488 self
.ensureVisible(scene_rect
)
1490 def _select_newest_child(self
):
1491 """Select the Nth child with the newest generation number (N > 1)"""
1492 selected_item
= self
.selected_item()
1493 if selected_item
is None:
1495 if len(selected_item
.commit
.children
) > 1:
1496 children
= selected_item
.commit
.children
[1:]
1498 children
= selected_item
.commit
.children
1499 child_item
= self
._newest
_item
(children
)
1500 if child_item
is None:
1502 selected_item
.setSelected(False)
1503 child_item
.setSelected(True)
1504 scene_rect
= child_item
.mapRectToScene(child_item
.boundingRect())
1505 self
.ensureVisible(scene_rect
)
1507 def set_initial_view(self
):
1508 self_commits
= self
.commits
1509 self_items
= self
.items
1511 commits
= self_commits
[-7:]
1512 items
= [self_items
[c
.oid
] for c
in commits
]
1514 selected
= self
.selected_items()
1516 items
.extend(selected
)
1518 self
.fit_view_to_items(items
)
1520 def zoom_to_fit(self
):
1521 """Fit selected items into the viewport"""
1523 items
= self
.selected_items()
1524 self
.fit_view_to_items(items
)
1526 def fit_view_to_items(self
, items
):
1528 rect
= self
.scene().itemsBoundingRect()
1530 x_min
= y_min
= maxsize
1531 x_max
= y_max
= -maxsize
1537 x_min
= min(x_min
, x
)
1538 x_max
= max(x_max
, x
)
1539 y_min
= min(y_min
, y
)
1540 y_max
= max(y_max
, y
)
1542 rect
= QtCore
.QRectF(x_min
, y_min
,
1546 x_adjust
= abs(GraphView
.x_adjust
)
1547 y_adjust
= abs(GraphView
.y_adjust
)
1549 count
= max(2.0, 10.0 - len(items
)/2.0)
1550 y_offset
= int(y_adjust
* count
)
1551 x_offset
= int(x_adjust
* count
)
1552 rect
.setX(rect
.x() - x_offset
//2)
1553 rect
.setY(rect
.y() - y_adjust
//2)
1554 rect
.setHeight(rect
.height() + y_offset
)
1555 rect
.setWidth(rect
.width() + x_offset
)
1557 self
.fitInView(rect
, Qt
.KeepAspectRatio
)
1558 self
.scene().invalidate()
1560 def save_selection(self
, event
):
1561 if event
.button() != Qt
.LeftButton
:
1563 elif Qt
.ShiftModifier
!= event
.modifiers():
1565 self
.selection_list
= self
.selected_items()
1567 def restore_selection(self
, event
):
1568 if Qt
.ShiftModifier
!= event
.modifiers():
1570 for item
in self
.selection_list
:
1571 item
.setSelected(True)
1573 def handle_event(self
, event_handler
, event
):
1574 self
.save_selection(event
)
1575 event_handler(self
, event
)
1576 self
.restore_selection(event
)
1579 def set_selecting(self
, selecting
):
1580 self
.selecting
= selecting
1582 def pan(self
, event
):
1584 dx
= pos
.x() - self
.mouse_start
[0]
1585 dy
= pos
.y() - self
.mouse_start
[1]
1587 if dx
== 0 and dy
== 0:
1590 rect
= QtCore
.QRect(0, 0, abs(dx
), abs(dy
))
1591 delta
= self
.mapToScene(rect
).boundingRect()
1601 matrix
= self
.transform()
1603 matrix
*= self
.saved_matrix
1604 matrix
.translate(tx
, ty
)
1606 self
.setTransformationAnchor(QtWidgets
.QGraphicsView
.NoAnchor
)
1607 self
.setTransform(matrix
)
1609 def wheel_zoom(self
, event
):
1610 """Handle mouse wheel zooming."""
1611 delta
= qtcompat
.wheel_delta(event
)
1612 zoom
= math
.pow(2.0, delta
/512.0)
1613 factor
= (self
.transform()
1615 .mapRect(QtCore
.QRectF(0.0, 0.0, 1.0, 1.0))
1617 if factor
< 0.014 or factor
> 42.0:
1619 self
.setTransformationAnchor(QtWidgets
.QGraphicsView
.AnchorUnderMouse
)
1621 self
.scale(zoom
, zoom
)
1623 def wheel_pan(self
, event
):
1624 """Handle mouse wheel panning."""
1625 unit
= QtCore
.QRectF(0.0, 0.0, 1.0, 1.0)
1626 factor
= 1.0 / self
.transform().mapRect(unit
).width()
1627 tx
, ty
= qtcompat
.wheel_translation(event
)
1629 matrix
= self
.transform().translate(tx
* factor
, ty
* factor
)
1630 self
.setTransformationAnchor(QtWidgets
.QGraphicsView
.NoAnchor
)
1631 self
.setTransform(matrix
)
1633 def scale_view(self
, scale
):
1634 factor
= (self
.transform()
1635 .scale(scale
, scale
)
1636 .mapRect(QtCore
.QRectF(0, 0, 1, 1))
1638 if factor
< 0.07 or factor
> 100.0:
1642 adjust_scrollbars
= True
1643 scrollbar
= self
.verticalScrollBar()
1645 value
= get(scrollbar
)
1646 min_
= scrollbar
.minimum()
1647 max_
= scrollbar
.maximum()
1648 range_
= max_
- min_
1649 distance
= value
- min_
1650 nonzero_range
= range_
> 0.1
1652 scrolloffset
= distance
/range_
1654 adjust_scrollbars
= False
1656 self
.setTransformationAnchor(QtWidgets
.QGraphicsView
.NoAnchor
)
1657 self
.scale(scale
, scale
)
1659 scrollbar
= self
.verticalScrollBar()
1660 if scrollbar
and adjust_scrollbars
:
1661 min_
= scrollbar
.minimum()
1662 max_
= scrollbar
.maximum()
1663 range_
= max_
- min_
1664 value
= min_
+ int(float(range_
) * scrolloffset
)
1665 scrollbar
.setValue(value
)
1667 def add_commits(self
, commits
):
1668 """Traverse commits and add them to the view."""
1669 self
.commits
.extend(commits
)
1670 scene
= self
.scene()
1671 for commit
in commits
:
1672 item
= Commit(commit
, self
.notifier
)
1673 self
.items
[commit
.oid
] = item
1674 for ref
in commit
.tags
:
1675 self
.items
[ref
] = item
1678 self
.layout_commits()
1681 def link(self
, commits
):
1682 """Create edges linking commits with their parents"""
1683 scene
= self
.scene()
1684 for commit
in commits
:
1686 commit_item
= self
.items
[commit
.oid
]
1688 # TODO - Handle truncated history viewing
1690 for parent
in reversed(commit
.parents
):
1692 parent_item
= self
.items
[parent
.oid
]
1694 # TODO - Handle truncated history viewing
1697 edge
= parent_item
.edges
[commit
.oid
]
1699 edge
= Edge(parent_item
, commit_item
)
1702 parent_item
.edges
[commit
.oid
] = edge
1703 commit_item
.edges
[parent
.oid
] = edge
1706 def layout_commits(self
):
1707 positions
= self
.position_nodes()
1709 # Each edge is accounted in two commits. Hence, accumulate invalid
1710 # edges to prevent double edge invalidation.
1711 invalid_edges
= set()
1713 for oid
, (x
, y
) in positions
.items():
1714 item
= self
.items
[oid
]
1720 for edge
in item
.edges
.values():
1721 invalid_edges
.add(edge
)
1723 for edge
in invalid_edges
:
1724 edge
.commits_were_invalidated()
1726 # Commit node layout technique
1728 # Nodes are aligned by a mesh. Columns and rows are distributed using
1729 # algorithms described below.
1731 # Row assignment algorithm
1733 # The algorithm aims consequent.
1734 # 1. A commit should be above all its parents.
1735 # 2. No commit should be at right side of a commit with a tag in same row.
1736 # This prevents overlapping of tag labels with commits and other labels.
1737 # 3. Commit density should be maximized.
1739 # The algorithm requires that all parents of a commit were assigned column.
1740 # Nodes must be traversed in generation ascend order. This guarantees that all
1741 # parents of a commit were assigned row. So, the algorithm may operate in
1742 # course of column assignment algorithm.
1744 # Row assignment uses frontier. A frontier is a dictionary that contains
1745 # minimum available row index for each column. It propagates during the
1746 # algorithm. Set of cells with tags is also maintained to meet second aim.
1748 # Initialization is performed by reset_rows method. Each new column should
1749 # be declared using declare_column method. Getting row for a cell is
1750 # implemented in alloc_cell method. Frontier must be propagated for any child
1751 # of fork commit which occupies different column. This meets first aim.
1753 # Column assignment algorithm
1755 # The algorithm traverses nodes in generation ascend order. This guarantees
1756 # that a node will be visited after all its parents.
1758 # The set of occupied columns are maintained during work. Initially it is
1759 # empty and no node occupied a column. Empty columns are allocated on demand.
1760 # Free index for column being allocated is searched in following way.
1761 # 1. Start from desired column and look towards graph center (0 column).
1762 # 2. Start from center and look in both directions simultaneously.
1763 # Desired column is defaulted to 0. Fork node should set desired column for
1764 # children equal to its one. This prevents branch from jumping too far from
1767 # Initialization is performed by reset_columns method. Column allocation is
1768 # implemented in alloc_column method. Initialization and main loop are in
1769 # recompute_grid method. The method also embeds row assignment algorithm by
1772 # Actions for each node are follow.
1773 # 1. If the node was not assigned a column then it is assigned empty one.
1775 # 3. Allocate columns for children.
1776 # If a child have a column assigned then it should no be overridden. One of
1777 # children is assigned same column as the node. If the node is a fork then the
1778 # child is chosen in generation descent order. This is a heuristic and it only
1779 # affects resulting appearance of the graph. Other children are assigned empty
1780 # columns in same order. It is the heuristic too.
1781 # 4. If no child occupies column of the node then leave it.
1782 # It is possible in consequent situations.
1783 # 4.1 The node is a leaf.
1784 # 4.2 The node is a fork and all its children are already assigned side
1785 # column. It is possible if all the children are merges.
1786 # 4.3 Single node child is a merge that is already assigned a column.
1787 # 5. Propagate frontier with respect to this node.
1788 # Each frontier entry corresponding to column occupied by any node's child
1789 # must be gather than node row index. This meets first aim of the row
1790 # assignment algorithm.
1791 # Note that frontier of child that occupies same row was propagated during
1792 # step 2. Hence, it must be propagated for children on side columns.
1794 def reset_columns(self
):
1795 # Some children of displayed commits might not be accounted in
1796 # 'commits' list. It is common case during loading of big graph.
1797 # But, they are assigned a column that must be reseted. Hence, use
1798 # depth-first traversal to reset all columns assigned.
1799 for node
in self
.commits
:
1800 if node
.column
is None:
1806 for child
in node
.children
:
1807 if child
.column
is not None:
1814 def reset_rows(self
):
1816 self
.tagged_cells
= set()
1818 def declare_column(self
, column
):
1820 # Align new column frontier by frontier of nearest column. If all
1821 # columns were left then select maximum frontier value.
1822 if not self
.columns
:
1823 self
.frontier
[column
] = max(list(self
.frontier
.values()))
1825 # This is heuristic that mostly affects roots. Note that the
1826 # frontier values for fork children will be overridden in course of
1827 # propagate_frontier.
1828 for offset
in itertools
.count(1):
1829 for c
in [column
+ offset
, column
- offset
]:
1830 if c
not in self
.columns
:
1831 # Column 'c' is not occupied.
1834 frontier
= self
.frontier
[c
]
1836 # Column 'c' was never allocated.
1840 # The frontier of the column may be higher because of
1841 # tag overlapping prevention performed for previous head.
1843 if self
.frontier
[column
] >= frontier
:
1848 self
.frontier
[column
] = frontier
1854 # First commit must be assigned 0 row.
1855 self
.frontier
[column
] = 0
1857 def alloc_column(self
, column
=0):
1858 columns
= self
.columns
1859 # First, look for free column by moving from desired column to graph
1860 # center (column 0).
1861 for c
in range(column
, 0, -1 if column
> 0 else 1):
1862 if c
not in columns
:
1863 if c
> self
.max_column
:
1865 elif c
< self
.min_column
:
1869 # If no free column was found between graph center and desired
1870 # column then look for free one by moving from center along both
1871 # directions simultaneously.
1872 for c
in itertools
.count(0):
1873 if c
not in columns
:
1874 if c
> self
.max_column
:
1878 if c
not in columns
:
1879 if c
< self
.min_column
:
1882 self
.declare_column(c
)
1886 def alloc_cell(self
, column
, tags
):
1887 # Get empty cell from frontier.
1888 cell_row
= self
.frontier
[column
]
1891 # Prevent overlapping of tag with cells already allocated a row.
1893 can_overlap
= list(range(column
+ 1, self
.max_column
+ 1))
1895 can_overlap
= list(range(column
- 1, self
.min_column
- 1, -1))
1896 for c
in can_overlap
:
1897 frontier
= self
.frontier
[c
]
1898 if frontier
> cell_row
:
1901 # Avoid overlapping with tags of commits at cell_row.
1903 can_overlap
= list(range(self
.min_column
, column
))
1905 can_overlap
= list(range(self
.max_column
, column
, -1))
1906 for cell_row
in itertools
.count(cell_row
):
1907 for c
in can_overlap
:
1908 if (c
, cell_row
) in self
.tagged_cells
:
1909 # Overlapping. Try next row.
1912 # No overlapping was found.
1914 # Note that all checks should be made for new cell_row value.
1917 self
.tagged_cells
.add((column
, cell_row
))
1919 # Propagate frontier.
1920 self
.frontier
[column
] = cell_row
+ 1
1923 def propagate_frontier(self
, column
, value
):
1924 current
= self
.frontier
[column
]
1926 self
.frontier
[column
] = value
1928 def leave_column(self
, column
):
1929 count
= self
.columns
[column
]
1931 del self
.columns
[column
]
1933 self
.columns
[column
] = count
- 1
1935 def recompute_grid(self
):
1936 self
.reset_columns()
1939 for node
in sort_by_generation(list(self
.commits
)):
1940 if node
.column
is None:
1941 # Node is either root or its parent is not in items. The last
1942 # happens when tree loading is in progress. Allocate new
1943 # columns for such nodes.
1944 node
.column
= self
.alloc_column()
1946 node
.row
= self
.alloc_cell(node
.column
, node
.tags
)
1948 # Allocate columns for children which are still without one. Also
1949 # propagate frontier for children.
1951 sorted_children
= sorted(node
.children
,
1952 key
=lambda c
: c
.generation
,
1954 citer
= iter(sorted_children
)
1956 if child
.column
is None:
1957 # Top most child occupies column of parent.
1958 child
.column
= node
.column
1959 # Note that frontier is propagated in course of
1963 self
.propagate_frontier(child
.column
, node
.row
+ 1)
1965 # No child occupies same column.
1966 self
.leave_column(node
.column
)
1967 # Note that the loop below will pass no iteration.
1969 # Rest children are allocated new column.
1971 if child
.column
is None:
1972 child
.column
= self
.alloc_column(node
.column
)
1973 self
.propagate_frontier(child
.column
, node
.row
+ 1)
1975 child
= node
.children
[0]
1976 if child
.column
is None:
1977 child
.column
= node
.column
1978 # Note that frontier is propagated in course of alloc_cell.
1979 elif child
.column
!= node
.column
:
1980 # Child node have other parents and occupies column of one
1982 self
.leave_column(node
.column
)
1983 # But frontier must be propagated with respect to this
1985 self
.propagate_frontier(child
.column
, node
.row
+ 1)
1987 # This is a leaf node.
1988 self
.leave_column(node
.column
)
1990 def position_nodes(self
):
1991 self
.recompute_grid()
1993 x_start
= self
.x_start
2000 for node
in self
.commits
:
2001 x_pos
= x_start
+ node
.column
* x_off
2002 y_pos
= y_off
+ node
.row
* y_off
2004 positions
[node
.oid
] = (x_pos
, y_pos
)
2005 x_min
= min(x_min
, x_pos
)
2012 def contextMenuEvent(self
, event
):
2013 self
.context_menu_event(event
)
2015 def mousePressEvent(self
, event
):
2016 if event
.button() == Qt
.MidButton
:
2018 self
.mouse_start
= [pos
.x(), pos
.y()]
2019 self
.saved_matrix
= self
.transform()
2020 self
.is_panning
= True
2022 if event
.button() == Qt
.RightButton
:
2025 if event
.button() == Qt
.LeftButton
:
2027 self
.handle_event(QtWidgets
.QGraphicsView
.mousePressEvent
, event
)
2029 def mouseMoveEvent(self
, event
):
2030 pos
= self
.mapToScene(event
.pos())
2034 self
.last_mouse
[0] = pos
.x()
2035 self
.last_mouse
[1] = pos
.y()
2036 self
.handle_event(QtWidgets
.QGraphicsView
.mouseMoveEvent
, event
)
2038 self
.viewport().repaint()
2040 def mouseReleaseEvent(self
, event
):
2041 self
.pressed
= False
2042 if event
.button() == Qt
.MidButton
:
2043 self
.is_panning
= False
2045 self
.handle_event(QtWidgets
.QGraphicsView
.mouseReleaseEvent
, event
)
2046 self
.selection_list
= []
2047 self
.viewport().repaint()
2049 def wheelEvent(self
, event
):
2050 """Handle Qt mouse wheel events."""
2051 if event
.modifiers() & Qt
.ControlModifier
:
2052 self
.wheel_zoom(event
)
2054 self
.wheel_pan(event
)
2056 def fitInView(self
, rect
, flags
=Qt
.IgnoreAspectRatio
):
2057 """Override fitInView to remove unwanted margins
2059 https://bugreports.qt.io/browse/QTBUG-42331 - based on QT sources
2062 if self
.scene() is None or rect
.isNull():
2064 unity
= self
.transform().mapRect(QtCore
.QRectF(0, 0, 1, 1))
2065 self
.scale(1.0/unity
.width(), 1.0/unity
.height())
2066 view_rect
= self
.viewport().rect()
2067 scene_rect
= self
.transform().mapRect(rect
)
2068 xratio
= view_rect
.width() / scene_rect
.width()
2069 yratio
= view_rect
.height() / scene_rect
.height()
2070 if flags
== Qt
.KeepAspectRatio
:
2071 xratio
= yratio
= min(xratio
, yratio
)
2072 elif flags
== Qt
.KeepAspectRatioByExpanding
:
2073 xratio
= yratio
= max(xratio
, yratio
)
2074 self
.scale(xratio
, yratio
)
2075 self
.centerOn(rect
.center())
2078 def sort_by_generation(commits
):
2079 if len(commits
) < 2:
2081 commits
.sort(key
=lambda x
: x
.generation
)
2087 # oid -- Git objects IDs (i.e. SHA-1 IDs)
2088 # ref -- Git references that resolve to a commit-ish (HEAD, branches, tags)