1 from __future__
import division
, absolute_import
, unicode_literals
4 from itertools
import count
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
17 from .. import difftool
18 from .. import hotkeys
20 from .. import observable
21 from .. import qtcompat
22 from .. import qtutils
25 from . import completion
26 from . import createbranch
27 from . import createtag
30 from . import filelist
31 from . import standard
34 def git_dag(model
, args
=None, settings
=None, existing_view
=None):
35 """Return a pre-populated git DAG widget."""
36 branch
= model
.currentbranch
37 # disambiguate between branch names and filenames by using '--'
38 branch_doubledash
= branch
and (branch
+ ' --') or ''
39 ctx
= dag
.DAG(branch_doubledash
, 1000)
40 ctx
.set_arguments(args
)
42 if existing_view
is None:
43 view
= GitDAG(model
, ctx
, settings
=settings
)
52 class FocusRedirectProxy(object):
53 """Redirect actions from the main widget to child widgets"""
55 def __init__(self
, *widgets
):
56 """Provide proxied widgets; the default widget must be first"""
57 self
.widgets
= widgets
58 self
.default
= widgets
[0]
60 def __getattr__(self
, name
):
61 return (lambda *args
, **kwargs
:
62 self
._forward
_action
(name
, *args
, **kwargs
))
64 def _forward_action(self
, name
, *args
, **kwargs
):
65 """Forward the captured action to the focused or default widget"""
66 widget
= QtWidgets
.QApplication
.focusWidget()
67 if widget
in self
.widgets
and hasattr(widget
, name
):
68 fn
= getattr(widget
, name
)
70 fn
= getattr(self
.default
, name
)
72 return fn(*args
, **kwargs
)
75 class ViewerMixin(object):
76 """Implementations must provide selected_items()"""
81 self
.menu_actions
= None # provided by implementation
83 def selected_item(self
):
84 """Return the currently selected item"""
85 selected_items
= self
.selected_items()
86 if not selected_items
:
88 return selected_items
[0]
90 def selected_oid(self
):
91 item
= self
.selected_item()
95 result
= item
.commit
.oid
98 def selected_oids(self
):
99 return [i
.commit
for i
in self
.selected_items()]
101 def with_oid(self
, fn
):
102 oid
= self
.selected_oid()
109 def diff_selected_this(self
):
110 clicked_oid
= self
.clicked
.oid
111 selected_oid
= self
.selected
.oid
112 self
.diff_commits
.emit(selected_oid
, clicked_oid
)
114 def diff_this_selected(self
):
115 clicked_oid
= self
.clicked
.oid
116 selected_oid
= self
.selected
.oid
117 self
.diff_commits
.emit(clicked_oid
, selected_oid
)
119 def cherry_pick(self
):
120 self
.with_oid(lambda oid
: cmds
.do(cmds
.CherryPick
, [oid
]))
122 def copy_to_clipboard(self
):
123 self
.with_oid(lambda oid
: qtutils
.set_clipboard(oid
))
125 def create_branch(self
):
126 self
.with_oid(lambda oid
: createbranch
.create_new_branch(revision
=oid
))
128 def create_tag(self
):
129 self
.with_oid(lambda oid
: createtag
.create_tag(ref
=oid
))
131 def create_tarball(self
):
132 self
.with_oid(lambda oid
: archive
.show_save_dialog(oid
, parent
=self
))
135 self
.with_oid(lambda oid
:
136 difftool
.diff_expression(self
, oid
+ '^!',
137 hide_expr
=False, focus_tree
=True))
139 def show_dir_diff(self
):
140 self
.with_oid(lambda oid
:
141 cmds
.difftool_launch(left
=oid
, left_take_magic
=True,
144 def reset_branch_head(self
):
145 self
.with_oid(lambda oid
: cmds
.do(cmds
.ResetBranchHead
, ref
=oid
))
147 def reset_worktree(self
):
148 self
.with_oid(lambda oid
: cmds
.do(cmds
.ResetWorktree
, ref
=oid
))
150 def save_blob_dialog(self
):
151 self
.with_oid(lambda oid
: browse
.BrowseDialog
.browse(oid
))
153 def update_menu_actions(self
, event
):
154 selected_items
= self
.selected_items()
155 item
= self
.itemAt(event
.pos())
157 self
.clicked
= commit
= None
159 self
.clicked
= commit
= item
.commit
161 has_single_selection
= len(selected_items
) == 1
162 has_selection
= bool(selected_items
)
163 can_diff
= bool(commit
and has_single_selection
and
164 commit
is not selected_items
[0].commit
)
167 self
.selected
= selected_items
[0].commit
171 self
.menu_actions
['diff_this_selected'].setEnabled(can_diff
)
172 self
.menu_actions
['diff_selected_this'].setEnabled(can_diff
)
173 self
.menu_actions
['diff_commit'].setEnabled(has_single_selection
)
174 self
.menu_actions
['diff_commit_all'].setEnabled(has_single_selection
)
176 self
.menu_actions
['cherry_pick'].setEnabled(has_single_selection
)
177 self
.menu_actions
['copy'].setEnabled(has_single_selection
)
178 self
.menu_actions
['create_branch'].setEnabled(has_single_selection
)
179 self
.menu_actions
['create_patch'].setEnabled(has_selection
)
180 self
.menu_actions
['create_tag'].setEnabled(has_single_selection
)
181 self
.menu_actions
['create_tarball'].setEnabled(has_single_selection
)
182 self
.menu_actions
['reset_branch_head'].setEnabled(has_single_selection
)
183 self
.menu_actions
['reset_worktree'].setEnabled(has_single_selection
)
184 self
.menu_actions
['save_blob'].setEnabled(has_single_selection
)
186 def context_menu_event(self
, event
):
187 self
.update_menu_actions(event
)
188 menu
= qtutils
.create_menu(N_('Actions'), self
)
189 menu
.addAction(self
.menu_actions
['diff_this_selected'])
190 menu
.addAction(self
.menu_actions
['diff_selected_this'])
191 menu
.addAction(self
.menu_actions
['diff_commit'])
192 menu
.addAction(self
.menu_actions
['diff_commit_all'])
194 menu
.addAction(self
.menu_actions
['create_branch'])
195 menu
.addAction(self
.menu_actions
['create_tag'])
197 menu
.addAction(self
.menu_actions
['cherry_pick'])
198 menu
.addAction(self
.menu_actions
['create_patch'])
199 menu
.addAction(self
.menu_actions
['create_tarball'])
201 reset_menu
= menu
.addMenu(N_('Reset'))
202 reset_menu
.addAction(self
.menu_actions
['reset_branch_head'])
203 reset_menu
.addAction(self
.menu_actions
['reset_worktree'])
205 menu
.addAction(self
.menu_actions
['save_blob'])
206 menu
.addAction(self
.menu_actions
['copy'])
207 menu
.exec_(self
.mapToGlobal(event
.pos()))
210 def viewer_actions(widget
):
212 'diff_this_selected':
213 qtutils
.add_action(widget
, N_('Diff this -> selected'),
214 widget
.proxy
.diff_this_selected
),
215 'diff_selected_this':
216 qtutils
.add_action(widget
, N_('Diff selected -> this'),
217 widget
.proxy
.diff_selected_this
),
219 qtutils
.add_action(widget
, N_('Create Branch'),
220 widget
.proxy
.create_branch
),
222 qtutils
.add_action(widget
, N_('Create Patch'),
223 widget
.proxy
.create_patch
),
225 qtutils
.add_action(widget
, N_('Create Tag'),
226 widget
.proxy
.create_tag
),
228 qtutils
.add_action(widget
, N_('Save As Tarball/Zip...'),
229 widget
.proxy
.create_tarball
),
231 qtutils
.add_action(widget
, N_('Cherry Pick'),
232 widget
.proxy
.cherry_pick
),
234 qtutils
.add_action(widget
, N_('Launch Diff Tool'),
235 widget
.proxy
.show_diff
, hotkeys
.DIFF
),
237 qtutils
.add_action(widget
, N_('Launch Directory Diff Tool'),
238 widget
.proxy
.show_dir_diff
, hotkeys
.DIFF_SECONDARY
),
240 qtutils
.add_action(widget
, N_('Reset Branch Head'),
241 widget
.proxy
.reset_branch_head
),
243 qtutils
.add_action(widget
, N_('Reset Worktree'),
244 widget
.proxy
.reset_worktree
),
246 qtutils
.add_action(widget
, N_('Grab File...'),
247 widget
.proxy
.save_blob_dialog
),
249 qtutils
.add_action(widget
, N_('Copy SHA-1'),
250 widget
.proxy
.copy_to_clipboard
,
251 QtGui
.QKeySequence
.Copy
),
255 class CommitTreeWidgetItem(QtWidgets
.QTreeWidgetItem
):
257 def __init__(self
, commit
, parent
=None):
258 QtWidgets
.QTreeWidgetItem
.__init
__(self
, parent
)
260 self
.setText(0, commit
.summary
)
261 self
.setText(1, commit
.author
)
262 self
.setText(2, commit
.authdate
)
265 class CommitTreeWidget(standard
.TreeWidget
, ViewerMixin
):
267 diff_commits
= Signal(object, object)
269 def __init__(self
, notifier
, parent
):
270 standard
.TreeWidget
.__init
__(self
, parent
=parent
)
271 ViewerMixin
.__init
__(self
)
273 self
.setSelectionMode(self
.ExtendedSelection
)
274 self
.setHeaderLabels([N_('Summary'), N_('Author'), N_('Date, Time')])
277 self
.menu_actions
= None
278 self
.notifier
= notifier
279 self
.selecting
= False
282 self
.action_up
= qtutils
.add_action(self
, N_('Go Up'),
283 self
.go_up
, hotkeys
.MOVE_UP
)
285 self
.action_down
= qtutils
.add_action(self
, N_('Go Down'),
286 self
.go_down
, hotkeys
.MOVE_DOWN
)
288 notifier
.add_observer(diff
.COMMITS_SELECTED
, self
.commits_selected
)
290 self
.itemSelectionChanged
.connect(self
.selection_changed
)
294 self
.goto(self
.itemAbove
)
297 self
.goto(self
.itemBelow
)
299 def goto(self
, finder
):
300 items
= self
.selected_items()
301 item
= items
and items
[0] or None
306 self
.select([found
.commit
.oid
])
308 def selected_commit_range(self
):
309 selected_items
= self
.selected_items()
310 if not selected_items
:
312 return selected_items
[-1].commit
.oid
, selected_items
[0].commit
.oid
314 def set_selecting(self
, selecting
):
315 self
.selecting
= selecting
317 def selection_changed(self
):
318 items
= self
.selected_items()
321 self
.set_selecting(True)
322 self
.notifier
.notify_observers(diff
.COMMITS_SELECTED
,
323 [i
.commit
for i
in items
])
324 self
.set_selecting(False)
326 def commits_selected(self
, commits
):
329 with qtutils
.BlockSignals(self
):
330 self
.select([commit
.oid
for commit
in commits
])
332 def select(self
, oids
):
335 self
.clearSelection()
336 for idx
, oid
in enumerate(oids
):
338 item
= self
.oidmap
[oid
]
341 self
.scrollToItem(item
)
342 item
.setSelected(True)
344 def adjust_columns(self
):
345 width
= self
.width()-20
348 self
.setColumnWidth(0, zero
)
349 self
.setColumnWidth(1, onetwo
)
350 self
.setColumnWidth(2, onetwo
)
353 QtWidgets
.QTreeWidget
.clear(self
)
357 def add_commits(self
, commits
):
358 self
.commits
.extend(commits
)
360 for c
in reversed(commits
):
361 item
= CommitTreeWidgetItem(c
)
363 self
.oidmap
[c
.oid
] = item
365 self
.oidmap
[tag
] = item
366 self
.insertTopLevelItems(0, items
)
368 def create_patch(self
):
369 items
= self
.selectedItems()
372 oids
= [item
.commit
.oid
for item
in reversed(items
)]
373 all_oids
= [c
.oid
for c
in self
.commits
]
374 cmds
.do(cmds
.FormatPatch
, oids
, all_oids
)
377 def contextMenuEvent(self
, event
):
378 self
.context_menu_event(event
)
380 def mousePressEvent(self
, event
):
381 if event
.button() == Qt
.RightButton
:
384 QtWidgets
.QTreeWidget
.mousePressEvent(self
, event
)
387 class GitDAG(standard
.MainWindow
):
388 """The git-dag widget."""
391 def __init__(self
, model
, ctx
, parent
=None, settings
=None):
392 super(GitDAG
, self
).__init
__(parent
)
394 self
.setMinimumSize(420, 420)
396 # change when widgets are added/removed
397 self
.widget_version
= 2
400 self
.settings
= settings
403 self
.commit_list
= []
407 self
.revtext
= completion
.GitLogLineEdit()
408 self
.maxresults
= standard
.SpinBox()
410 self
.zoom_out
= qtutils
.create_action_button(
411 tooltip
=N_('Zoom Out'), icon
=icons
.zoom_out())
413 self
.zoom_in
= qtutils
.create_action_button(
414 tooltip
=N_('Zoom In'), icon
=icons
.zoom_in())
416 self
.zoom_to_fit
= qtutils
.create_action_button(
417 tooltip
=N_('Zoom to Fit'), icon
=icons
.zoom_fit_best())
419 self
.notifier
= notifier
= observable
.Observable()
420 self
.notifier
.refs_updated
= refs_updated
= 'refs_updated'
421 self
.notifier
.add_observer(refs_updated
, self
.display
)
422 self
.notifier
.add_observer(filelist
.HISTORIES_SELECTED
,
423 self
.histories_selected
)
424 self
.notifier
.add_observer(filelist
.DIFFTOOL_SELECTED
,
425 self
.difftool_selected
)
426 self
.notifier
.add_observer(diff
.COMMITS_SELECTED
, self
.commits_selected
)
428 self
.treewidget
= CommitTreeWidget(notifier
, self
)
429 self
.diffwidget
= diff
.DiffWidget(notifier
, self
, is_commit
=True)
430 self
.filewidget
= filelist
.FileWidget(notifier
, self
)
431 self
.graphview
= GraphView(notifier
, self
)
433 self
.proxy
= FocusRedirectProxy(self
.treewidget
,
437 self
.viewer_actions
= actions
= viewer_actions(self
)
438 self
.treewidget
.menu_actions
= actions
439 self
.graphview
.menu_actions
= actions
441 self
.controls_layout
= qtutils
.hbox(defs
.no_margin
, defs
.spacing
,
442 self
.revtext
, self
.maxresults
)
444 self
.controls_widget
= QtWidgets
.QWidget()
445 self
.controls_widget
.setLayout(self
.controls_layout
)
447 self
.log_dock
= qtutils
.create_dock(N_('Log'), self
, stretch
=False)
448 self
.log_dock
.setWidget(self
.treewidget
)
449 log_dock_titlebar
= self
.log_dock
.titleBarWidget()
450 log_dock_titlebar
.add_corner_widget(self
.controls_widget
)
452 self
.file_dock
= qtutils
.create_dock(N_('Files'), self
)
453 self
.file_dock
.setWidget(self
.filewidget
)
455 self
.diff_dock
= qtutils
.create_dock(N_('Diff'), self
)
456 self
.diff_dock
.setWidget(self
.diffwidget
)
458 self
.graph_controls_layout
= qtutils
.hbox(
459 defs
.no_margin
, defs
.button_spacing
,
460 self
.zoom_out
, self
.zoom_in
, self
.zoom_to_fit
,
463 self
.graph_controls_widget
= QtWidgets
.QWidget()
464 self
.graph_controls_widget
.setLayout(self
.graph_controls_layout
)
466 self
.graphview_dock
= qtutils
.create_dock(N_('Graph'), self
)
467 self
.graphview_dock
.setWidget(self
.graphview
)
468 graph_titlebar
= self
.graphview_dock
.titleBarWidget()
469 graph_titlebar
.add_corner_widget(self
.graph_controls_widget
)
471 self
.lock_layout_action
= qtutils
.add_action_bool(
472 self
, N_('Lock Layout'), self
.set_lock_layout
, False)
474 self
.refresh_action
= qtutils
.add_action(
475 self
, N_('Refresh'), self
.refresh
, hotkeys
.REFRESH
)
477 # Create the application menu
478 self
.menubar
= QtWidgets
.QMenuBar(self
)
481 self
.view_menu
= qtutils
.create_menu(N_('View'), self
.menubar
)
482 self
.view_menu
.addAction(self
.refresh_action
)
484 self
.view_menu
.addAction(self
.log_dock
.toggleViewAction())
485 self
.view_menu
.addAction(self
.graphview_dock
.toggleViewAction())
486 self
.view_menu
.addAction(self
.diff_dock
.toggleViewAction())
487 self
.view_menu
.addAction(self
.file_dock
.toggleViewAction())
488 self
.view_menu
.addSeparator()
489 self
.view_menu
.addAction(self
.lock_layout_action
)
491 self
.menubar
.addAction(self
.view_menu
.menuAction())
492 self
.setMenuBar(self
.menubar
)
494 left
= Qt
.LeftDockWidgetArea
495 right
= Qt
.RightDockWidgetArea
496 self
.addDockWidget(left
, self
.log_dock
)
497 self
.addDockWidget(left
, self
.diff_dock
)
498 self
.addDockWidget(right
, self
.graphview_dock
)
499 self
.addDockWidget(right
, self
.file_dock
)
501 # Also re-loads dag.* from the saved state
502 self
.init_state(settings
, self
.resize_to_desktop
)
504 qtutils
.connect_button(self
.zoom_out
, self
.graphview
.zoom_out
)
505 qtutils
.connect_button(self
.zoom_in
, self
.graphview
.zoom_in
)
506 qtutils
.connect_button(self
.zoom_to_fit
,
507 self
.graphview
.zoom_to_fit
)
509 self
.treewidget
.diff_commits
.connect(self
.diff_commits
)
510 self
.graphview
.diff_commits
.connect(self
.diff_commits
)
512 self
.maxresults
.editingFinished
.connect(self
.display
)
513 self
.revtext
.textChanged
.connect(self
.text_changed
)
515 self
.revtext
.activated
.connect(self
.display
)
516 self
.revtext
.enter
.connect(self
.display
)
517 self
.revtext
.down
.connect(self
.focus_tree
)
519 # The model is updated in another thread so use
520 # signals/slots to bring control back to the main GUI thread
521 self
.model
.add_observer(self
.model
.message_updated
, self
.updated
.emit
)
522 self
.updated
.connect(self
.model_updated
, type=Qt
.QueuedConnection
)
524 qtutils
.add_action(self
, 'Focus Input', self
.focus_input
, hotkeys
.FOCUS
)
525 qtutils
.add_close_action(self
)
527 self
.set_context(ctx
)
529 def set_context(self
, ctx
):
532 # Update fields affected by model
533 self
.revtext
.setText(ctx
.ref
)
534 self
.maxresults
.setValue(ctx
.count
)
535 self
.update_window_title()
537 if self
.thread
is not None:
539 self
.thread
= ReaderThread(ctx
, self
)
542 thread
.begin
.connect(self
.thread_begin
, type=Qt
.QueuedConnection
)
543 thread
.status
.connect(self
.thread_status
, type=Qt
.QueuedConnection
)
544 thread
.add
.connect(self
.add_commits
, type=Qt
.QueuedConnection
)
545 thread
.end
.connect(self
.thread_end
, type=Qt
.QueuedConnection
)
547 def focus_input(self
):
548 self
.revtext
.setFocus()
550 def focus_tree(self
):
551 self
.treewidget
.setFocus()
553 def text_changed(self
, txt
):
555 self
.update_window_title()
557 def update_window_title(self
):
558 project
= self
.model
.project
560 self
.setWindowTitle(N_('%(project)s: %(ref)s - DAG')
561 % dict(project
=project
, ref
=self
.ctx
.ref
))
563 self
.setWindowTitle(project
+ N_(' - DAG'))
565 def export_state(self
):
566 state
= standard
.MainWindow
.export_state(self
)
567 state
['count'] = self
.ctx
.count
570 def apply_state(self
, state
):
571 result
= standard
.MainWindow
.apply_state(self
, state
)
573 count
= state
['count']
574 if self
.ctx
.overridden('count'):
575 count
= self
.ctx
.count
577 count
= self
.ctx
.count
579 self
.ctx
.set_count(count
)
580 self
.lock_layout_action
.setChecked(state
.get('lock_layout', False))
583 def model_updated(self
):
587 cmds
.do(cmds
.Refresh
)
590 new_ref
= self
.revtext
.value()
591 new_count
= self
.maxresults
.value()
594 self
.ctx
.set_ref(new_ref
)
595 self
.ctx
.set_count(new_count
)
599 standard
.MainWindow
.show(self
)
600 self
.treewidget
.adjust_columns()
602 def commits_selected(self
, commits
):
604 self
.selection
= commits
608 self
.commit_list
= []
609 self
.graphview
.clear()
610 self
.treewidget
.clear()
612 def add_commits(self
, commits
):
613 self
.commit_list
.extend(commits
)
614 # Keep track of commits
615 for commit_obj
in commits
:
616 self
.commits
[commit_obj
.oid
] = commit_obj
617 for tag
in commit_obj
.tags
:
618 self
.commits
[tag
] = commit_obj
619 self
.graphview
.add_commits(commits
)
620 self
.treewidget
.add_commits(commits
)
622 def thread_begin(self
):
625 def thread_end(self
):
627 self
.restore_selection()
629 def thread_status(self
, successful
):
630 self
.revtext
.hint
.set_error(not successful
)
632 def restore_selection(self
):
633 selection
= self
.selection
635 commit_obj
= self
.commit_list
[-1]
637 # No commits, exist, early-out
640 new_commits
= [self
.commits
.get(s
.oid
, None) for s
in selection
]
641 new_commits
= [c
for c
in new_commits
if c
is not None]
643 # The old selection exists in the new state
644 self
.notifier
.notify_observers(diff
.COMMITS_SELECTED
, new_commits
)
646 # The old selection is now empty. Select the top-most commit
647 self
.notifier
.notify_observers(diff
.COMMITS_SELECTED
, [commit_obj
])
649 self
.graphview
.update_scene_rect()
650 self
.graphview
.set_initial_view()
652 def diff_commits(self
, a
, b
):
653 paths
= self
.ctx
.paths()
655 cmds
.difftool_launch(left
=a
, right
=b
, paths
=paths
)
657 difftool
.diff_commits(self
, a
, b
)
660 def closeEvent(self
, event
):
661 self
.revtext
.close_popup()
663 standard
.MainWindow
.closeEvent(self
, event
)
665 def resizeEvent(self
, e
):
666 standard
.MainWindow
.resizeEvent(self
, e
)
667 self
.treewidget
.adjust_columns()
669 def histories_selected(self
, histories
):
670 argv
= [self
.model
.currentbranch
, '--']
671 argv
.extend(histories
)
672 text
= core
.list2cmdline(argv
)
673 self
.revtext
.setText(text
)
676 def difftool_selected(self
, files
):
677 bottom
, top
= self
.treewidget
.selected_commit_range()
680 cmds
.difftool_launch(left
=bottom
, left_take_parent
=True,
681 right
=top
, paths
=files
)
684 class ReaderThread(QtCore
.QThread
):
688 status
= Signal(object)
690 def __init__(self
, ctx
, parent
):
691 QtCore
.QThread
.__init
__(self
, parent
)
695 self
._mutex
= QtCore
.QMutex()
696 self
._condition
= QtCore
.QWaitCondition()
699 repo
= dag
.RepoReader(self
.ctx
)
706 self
._condition
.wait(self
._mutex
)
712 if len(commits
) >= 512:
713 self
.add
.emit(commits
)
716 self
.status
.emit(repo
.returncode
== 0)
718 self
.add
.emit(commits
)
724 QtCore
.QThread
.start(self
)
735 self
._condition
.wakeOne()
746 class Edge(QtWidgets
.QGraphicsItem
):
747 item_type
= QtWidgets
.QGraphicsItem
.UserType
+ 1
749 def __init__(self
, source
, dest
):
751 QtWidgets
.QGraphicsItem
.__init
__(self
)
753 self
.setAcceptedMouseButtons(Qt
.NoButton
)
756 self
.commit
= source
.commit
759 dest_pt
= Commit
.item_bbox
.center()
761 self
.source_pt
= self
.mapFromItem(self
.source
, dest_pt
)
762 self
.dest_pt
= self
.mapFromItem(self
.dest
, dest_pt
)
763 self
.line
= QtCore
.QLineF(self
.source_pt
, self
.dest_pt
)
765 width
= self
.dest_pt
.x() - self
.source_pt
.x()
766 height
= self
.dest_pt
.y() - self
.source_pt
.y()
767 rect
= QtCore
.QRectF(self
.source_pt
, QtCore
.QSizeF(width
, height
))
768 self
.bound
= rect
.normalized()
770 # Choose a new color for new branch edges
771 if self
.source
.x() < self
.dest
.x():
772 color
= EdgeColor
.cycle()
774 elif self
.source
.x() != self
.dest
.x():
775 color
= EdgeColor
.current()
778 color
= EdgeColor
.current()
781 self
.pen
= QtGui
.QPen(color
, 4.0, line
, Qt
.SquareCap
, Qt
.RoundJoin
)
785 return self
.item_type
787 def boundingRect(self
):
790 def paint(self
, painter
, option
, widget
):
791 QRectF
= QtCore
.QRectF
792 QPointF
= QtCore
.QPointF
797 painter
.setPen(self
.pen
)
798 path
= QtGui
.QPainterPath()
800 if self
.source
.x() == self
.dest
.x():
801 path
.moveTo(self
.source
.x(), self
.source
.y())
802 path
.lineTo(self
.dest
.x(), self
.dest
.y())
803 painter
.drawPath(path
)
805 # Define points starting from source
806 point1
= QPointF(self
.source
.x(), self
.source
.y())
807 point2
= QPointF(point1
.x(), point1
.y() - connector_length
)
808 point3
= QPointF(point2
.x() + arc_rect
, point2
.y() - arc_rect
)
810 # Define points starting from dest
811 point4
= QPointF(self
.dest
.x(), self
.dest
.y())
812 point5
= QPointF(point4
.x(), point3
.y() - arc_rect
)
813 point6
= QPointF(point5
.x() - arc_rect
, point5
.y() + arc_rect
)
815 start_angle_arc1
= 180
817 start_angle_arc2
= 90
818 span_angle_arc2
= -90
820 # If the dest is at the left of the source, then we
821 # need to reverse some values
822 if self
.source
.x() > self
.dest
.x():
823 point5
= QPointF(point4
.x(), point4
.y() + connector_length
)
824 point6
= QPointF(point5
.x() + arc_rect
, point5
.y() + arc_rect
)
825 point3
= QPointF(self
.source
.x() - arc_rect
, point6
.y())
826 point2
= QPointF(self
.source
.x(), point3
.y() + arc_rect
)
832 path
.arcTo(QRectF(point2
, point3
),
833 start_angle_arc1
, span_angle_arc1
)
835 path
.arcTo(QRectF(point6
, point5
),
836 start_angle_arc2
, span_angle_arc2
)
838 painter
.drawPath(path
)
841 class EdgeColor(object):
842 """An edge color factory"""
844 current_color_index
= 0
846 QtGui
.QColor(Qt
.red
),
847 QtGui
.QColor(Qt
.green
),
848 QtGui
.QColor(Qt
.blue
),
849 QtGui
.QColor(Qt
.black
),
850 QtGui
.QColor(Qt
.darkRed
),
851 QtGui
.QColor(Qt
.darkGreen
),
852 QtGui
.QColor(Qt
.darkBlue
),
853 QtGui
.QColor(Qt
.cyan
),
854 QtGui
.QColor(Qt
.magenta
),
855 # Orange; Qt.yellow is too low-contrast
856 qtutils
.rgba(0xff, 0x66, 0x00),
857 QtGui
.QColor(Qt
.gray
),
858 QtGui
.QColor(Qt
.darkCyan
),
859 QtGui
.QColor(Qt
.darkMagenta
),
860 QtGui
.QColor(Qt
.darkYellow
),
861 QtGui
.QColor(Qt
.darkGray
),
866 cls
.current_color_index
+= 1
867 cls
.current_color_index
%= len(cls
.colors
)
868 color
= cls
.colors
[cls
.current_color_index
]
874 return cls
.colors
[cls
.current_color_index
]
878 cls
.current_color_index
= 0
881 class Commit(QtWidgets
.QGraphicsItem
):
882 item_type
= QtWidgets
.QGraphicsItem
.UserType
+ 2
886 item_shape
= QtGui
.QPainterPath()
887 item_shape
.addRect(commit_radius
/-2.0,
889 commit_radius
, commit_radius
)
890 item_bbox
= item_shape
.boundingRect()
892 inner_rect
= QtGui
.QPainterPath()
893 inner_rect
.addRect(commit_radius
/-2.0 + 2.0,
894 commit_radius
/-2.0 + 2.0,
897 inner_rect
= inner_rect
.boundingRect()
899 commit_color
= QtGui
.QColor(Qt
.white
)
900 outline_color
= commit_color
.darker()
901 merge_color
= QtGui
.QColor(Qt
.lightGray
)
903 commit_selected_color
= QtGui
.QColor(Qt
.green
)
904 selected_outline_color
= commit_selected_color
.darker()
906 commit_pen
= QtGui
.QPen()
907 commit_pen
.setWidth(1.0)
908 commit_pen
.setColor(outline_color
)
910 def __init__(self
, commit
,
912 selectable
=QtWidgets
.QGraphicsItem
.ItemIsSelectable
,
913 cursor
=Qt
.PointingHandCursor
,
914 xpos
=commit_radius
/2.0 + 1.0,
915 cached_commit_color
=commit_color
,
916 cached_merge_color
=merge_color
):
918 QtWidgets
.QGraphicsItem
.__init
__(self
)
921 self
.notifier
= notifier
924 self
.setFlag(selectable
)
925 self
.setCursor(cursor
)
926 self
.setToolTip(commit
.oid
[:7] + ': ' + commit
.summary
)
929 self
.label
= label
= Label(commit
)
930 label
.setParentItem(self
)
931 label
.setPos(xpos
, -self
.commit_radius
/2.0)
935 if len(commit
.parents
) > 1:
936 self
.brush
= cached_merge_color
938 self
.brush
= cached_commit_color
943 def blockSignals(self
, blocked
):
944 self
.notifier
.notification_enabled
= not blocked
946 def itemChange(self
, change
, value
):
947 if change
== QtWidgets
.QGraphicsItem
.ItemSelectedHasChanged
:
948 # Broadcast selection to other widgets
949 selected_items
= self
.scene().selectedItems()
950 commits
= [item
.commit
for item
in selected_items
]
951 self
.scene().parent().set_selecting(True)
952 self
.notifier
.notify_observers(diff
.COMMITS_SELECTED
, commits
)
953 self
.scene().parent().set_selecting(False)
955 # Cache the pen for use in paint()
957 self
.brush
= self
.commit_selected_color
958 color
= self
.selected_outline_color
960 if len(self
.commit
.parents
) > 1:
961 self
.brush
= self
.merge_color
963 self
.brush
= self
.commit_color
964 color
= self
.outline_color
965 commit_pen
= QtGui
.QPen()
966 commit_pen
.setWidth(1.0)
967 commit_pen
.setColor(color
)
968 self
.commit_pen
= commit_pen
970 return QtWidgets
.QGraphicsItem
.itemChange(self
, change
, value
)
973 return self
.item_type
975 def boundingRect(self
, rect
=item_bbox
):
979 return self
.item_shape
981 def paint(self
, painter
, option
, widget
,
985 # Do not draw outside the exposed rect
986 painter
.setClipRect(option
.exposedRect
)
989 painter
.setPen(self
.commit_pen
)
990 painter
.setBrush(self
.brush
)
991 painter
.drawEllipse(inner
)
993 def mousePressEvent(self
, event
):
994 QtWidgets
.QGraphicsItem
.mousePressEvent(self
, event
)
996 self
.selected
= self
.isSelected()
998 def mouseMoveEvent(self
, event
):
1001 QtWidgets
.QGraphicsItem
.mouseMoveEvent(self
, event
)
1003 def mouseReleaseEvent(self
, event
):
1004 QtWidgets
.QGraphicsItem
.mouseReleaseEvent(self
, event
)
1005 if (not self
.dragged
and
1007 event
.button() == Qt
.LeftButton
):
1009 self
.pressed
= False
1010 self
.dragged
= False
1013 class Label(QtWidgets
.QGraphicsItem
):
1014 item_type
= QtWidgets
.QGraphicsItem
.UserType
+ 3
1019 item_shape
= QtGui
.QPainterPath()
1020 item_shape
.addRect(0, 0, width
, height
)
1021 item_bbox
= item_shape
.boundingRect()
1023 text_options
= QtGui
.QTextOption()
1024 text_options
.setAlignment(Qt
.AlignCenter
)
1025 text_options
.setAlignment(Qt
.AlignVCenter
)
1027 def __init__(self
, commit
,
1028 other_color
=QtGui
.QColor(Qt
.white
),
1029 head_color
=QtGui
.QColor(Qt
.green
)):
1030 QtWidgets
.QGraphicsItem
.__init
__(self
)
1033 # Starts with enough space for two tags. Any more and the commit
1034 # needs to be taller to accommodate.
1035 self
.commit
= commit
1037 if 'HEAD' in commit
.tags
:
1038 self
.color
= head_color
1040 self
.color
= other_color
1042 self
.color
.setAlpha(180)
1043 self
.pen
= QtGui
.QPen()
1044 self
.pen
.setColor(self
.color
.darker())
1045 self
.pen
.setWidth(1.0)
1048 return self
.item_type
1050 def boundingRect(self
, rect
=item_bbox
):
1054 return self
.item_shape
1056 def paint(self
, painter
, option
, widget
,
1057 text_opts
=text_options
,
1061 font
= cache
.label_font
1062 except AttributeError:
1063 font
= cache
.label_font
= QtWidgets
.QApplication
.font()
1064 font
.setPointSize(6)
1067 painter
.setBrush(self
.color
)
1068 painter
.setPen(self
.pen
)
1069 painter
.setFont(font
)
1073 QRectF
= QtCore
.QRectF
1074 for tag
in self
.commit
.tags
:
1075 text_rect
= painter
.boundingRect(
1076 QRectF(current_width
, 0, 0, 0), Qt
.TextSingleLine
, tag
)
1077 box_rect
= text_rect
.adjusted(-1, -1, 1, 1)
1078 painter
.drawRoundedRect(box_rect
, 2, 2)
1079 painter
.drawText(text_rect
, Qt
.TextSingleLine
, tag
)
1080 current_width
+= text_rect
.width() + 5
1083 class GraphView(QtWidgets
.QGraphicsView
, ViewerMixin
):
1085 diff_commits
= Signal(object, object)
1091 x_adjust
= Commit
.commit_radius
*4/3
1092 y_adjust
= Commit
.commit_radius
*4/3
1097 def __init__(self
, notifier
, parent
):
1098 QtWidgets
.QGraphicsView
.__init
__(self
, parent
)
1099 ViewerMixin
.__init
__(self
)
1101 highlight
= self
.palette().color(QtGui
.QPalette
.Highlight
)
1102 Commit
.commit_selected_color
= highlight
1103 Commit
.selected_outline_color
= highlight
.darker()
1105 self
.selection_list
= []
1106 self
.menu_actions
= None
1107 self
.notifier
= notifier
1110 self
.saved_matrix
= self
.transform()
1112 self
.x_offsets
= collections
.defaultdict(lambda: self
.x_min
)
1114 self
.is_panning
= False
1115 self
.pressed
= False
1116 self
.selecting
= False
1117 self
.last_mouse
= [0, 0]
1119 self
.setDragMode(self
.RubberBandDrag
)
1121 scene
= QtWidgets
.QGraphicsScene(self
)
1122 scene
.setItemIndexMethod(QtWidgets
.QGraphicsScene
.NoIndex
)
1123 self
.setScene(scene
)
1125 self
.setRenderHint(QtGui
.QPainter
.Antialiasing
)
1126 self
.setViewportUpdateMode(self
.BoundingRectViewportUpdate
)
1127 self
.setCacheMode(QtWidgets
.QGraphicsView
.CacheBackground
)
1128 self
.setTransformationAnchor(QtWidgets
.QGraphicsView
.AnchorUnderMouse
)
1129 self
.setResizeAnchor(QtWidgets
.QGraphicsView
.NoAnchor
)
1130 self
.setBackgroundBrush(QtGui
.QColor(Qt
.white
))
1132 qtutils
.add_action(self
, N_('Zoom In'), self
.zoom_in
,
1133 hotkeys
.ZOOM_IN
, hotkeys
.ZOOM_IN_SECONDARY
)
1135 qtutils
.add_action(self
, N_('Zoom Out'), self
.zoom_out
,
1138 qtutils
.add_action(self
, N_('Zoom to Fit'),
1139 self
.zoom_to_fit
, hotkeys
.FIT
)
1141 qtutils
.add_action(self
, N_('Select Parent'),
1142 self
.select_parent
, hotkeys
.MOVE_DOWN_TERTIARY
)
1144 qtutils
.add_action(self
, N_('Select Oldest Parent'),
1145 self
.select_oldest_parent
, hotkeys
.MOVE_DOWN
)
1147 qtutils
.add_action(self
, N_('Select Child'),
1148 self
.select_child
, hotkeys
.MOVE_UP_TERTIARY
)
1150 qtutils
.add_action(self
, N_('Select Newest Child'),
1151 self
.select_newest_child
, hotkeys
.MOVE_UP
)
1153 notifier
.add_observer(diff
.COMMITS_SELECTED
, self
.commits_selected
)
1157 self
.scene().clear()
1158 self
.selection_list
= []
1160 self
.x_offsets
.clear()
1165 # ViewerMixin interface
1166 def selected_items(self
):
1167 """Return the currently selected items"""
1168 return self
.scene().selectedItems()
1171 self
.scale_view(1.5)
1174 self
.scale_view(1.0/1.5)
1176 def commits_selected(self
, commits
):
1179 self
.select([commit
.oid
for commit
in commits
])
1181 def select(self
, oids
):
1182 """Select the item for the oids"""
1183 self
.scene().clearSelection()
1186 item
= self
.items
[oid
]
1189 item
.blockSignals(True)
1190 item
.setSelected(True)
1191 item
.blockSignals(False)
1192 item_rect
= item
.sceneTransform().mapRect(item
.boundingRect())
1193 self
.ensureVisible(item_rect
)
1195 def get_item_by_generation(self
, commits
, criteria_fn
):
1196 """Return the item for the commit matching criteria"""
1200 for commit
in commits
:
1201 if (generation
is None or
1202 criteria_fn(generation
, commit
.generation
)):
1204 generation
= commit
.generation
1206 return self
.items
[oid
]
1210 def oldest_item(self
, commits
):
1211 """Return the item for the commit with the oldest generation number"""
1212 return self
.get_item_by_generation(commits
, lambda a
, b
: a
> b
)
1214 def newest_item(self
, commits
):
1215 """Return the item for the commit with the newest generation number"""
1216 return self
.get_item_by_generation(commits
, lambda a
, b
: a
< b
)
1218 def create_patch(self
):
1219 items
= self
.selected_items()
1222 selected_commits
= self
.sort_by_generation([n
.commit
for n
in items
])
1223 oids
= [c
.oid
for c
in selected_commits
]
1224 all_oids
= [c
.oid
for c
in self
.commits
]
1225 cmds
.do(cmds
.FormatPatch
, oids
, all_oids
)
1227 def select_parent(self
):
1228 """Select the parent with the newest generation number"""
1229 selected_item
= self
.selected_item()
1230 if selected_item
is None:
1232 parent_item
= self
.newest_item(selected_item
.commit
.parents
)
1233 if parent_item
is None:
1235 selected_item
.setSelected(False)
1236 parent_item
.setSelected(True)
1238 parent_item
.mapRectToScene(parent_item
.boundingRect()))
1240 def select_oldest_parent(self
):
1241 """Select the parent with the oldest generation number"""
1242 selected_item
= self
.selected_item()
1243 if selected_item
is None:
1245 parent_item
= self
.oldest_item(selected_item
.commit
.parents
)
1246 if parent_item
is None:
1248 selected_item
.setSelected(False)
1249 parent_item
.setSelected(True)
1250 scene_rect
= parent_item
.mapRectToScene(parent_item
.boundingRect())
1251 self
.ensureVisible(scene_rect
)
1253 def select_child(self
):
1254 """Select the child with the oldest generation number"""
1255 selected_item
= self
.selected_item()
1256 if selected_item
is None:
1258 child_item
= self
.oldest_item(selected_item
.commit
.children
)
1259 if child_item
is None:
1261 selected_item
.setSelected(False)
1262 child_item
.setSelected(True)
1263 scene_rect
= child_item
.mapRectToScene(child_item
.boundingRect())
1264 self
.ensureVisible(scene_rect
)
1266 def select_newest_child(self
):
1267 """Select the Nth child with the newest generation number (N > 1)"""
1268 selected_item
= self
.selected_item()
1269 if selected_item
is None:
1271 if len(selected_item
.commit
.children
) > 1:
1272 children
= selected_item
.commit
.children
[1:]
1274 children
= selected_item
.commit
.children
1275 child_item
= self
.newest_item(children
)
1276 if child_item
is None:
1278 selected_item
.setSelected(False)
1279 child_item
.setSelected(True)
1280 scene_rect
= child_item
.mapRectToScene(child_item
.boundingRect())
1281 self
.ensureVisible(scene_rect
)
1283 def set_initial_view(self
):
1284 self_commits
= self
.commits
1285 self_items
= self
.items
1287 items
= self
.selected_items()
1289 commits
= self_commits
[-8:]
1290 items
= [self_items
[c
.oid
] for c
in commits
]
1292 self
.fit_view_to_items(items
)
1294 def zoom_to_fit(self
):
1295 """Fit selected items into the viewport"""
1297 items
= self
.selected_items()
1298 self
.fit_view_to_items(items
)
1300 def fit_view_to_items(self
, items
):
1302 rect
= self
.scene().itemsBoundingRect()
1304 x_min
= y_min
= maxsize
1305 x_max
= y_max
= -maxsize
1309 item_rect
= item
.boundingRect()
1310 x_off
= item_rect
.width() * 5
1311 y_off
= item_rect
.height() * 10
1312 x_min
= min(x_min
, pos
.x())
1313 y_min
= min(y_min
, pos
.y()-y_off
)
1314 x_max
= max(x_max
, pos
.x()+x_off
)
1315 y_max
= max(y_max
, pos
.y())
1316 rect
= QtCore
.QRectF(x_min
, y_min
, x_max
-x_min
, y_max
-y_min
)
1318 x_adjust
= GraphView
.x_adjust
1319 y_adjust
= GraphView
.y_adjust
1321 rect
.setX(rect
.x() - x_adjust
)
1322 rect
.setY(rect
.y() - y_adjust
)
1323 rect
.setHeight(rect
.height() + y_adjust
*2)
1324 rect
.setWidth(rect
.width() + x_adjust
*2)
1326 self
.fitInView(rect
, Qt
.KeepAspectRatio
)
1327 self
.scene().invalidate()
1329 def save_selection(self
, event
):
1330 if event
.button() != Qt
.LeftButton
:
1332 elif Qt
.ShiftModifier
!= event
.modifiers():
1334 self
.selection_list
= self
.selected_items()
1336 def restore_selection(self
, event
):
1337 if Qt
.ShiftModifier
!= event
.modifiers():
1339 for item
in self
.selection_list
:
1340 item
.setSelected(True)
1342 def handle_event(self
, event_handler
, event
):
1343 self
.save_selection(event
)
1344 event_handler(self
, event
)
1345 self
.restore_selection(event
)
1348 def set_selecting(self
, selecting
):
1349 self
.selecting
= selecting
1351 def pan(self
, event
):
1353 dx
= pos
.x() - self
.mouse_start
[0]
1354 dy
= pos
.y() - self
.mouse_start
[1]
1356 if dx
== 0 and dy
== 0:
1359 rect
= QtCore
.QRect(0, 0, abs(dx
), abs(dy
))
1360 delta
= self
.mapToScene(rect
).boundingRect()
1370 matrix
= self
.transform()
1372 matrix
*= self
.saved_matrix
1373 matrix
.translate(tx
, ty
)
1375 self
.setTransformationAnchor(QtWidgets
.QGraphicsView
.NoAnchor
)
1376 self
.setTransform(matrix
)
1378 def wheel_zoom(self
, event
):
1379 """Handle mouse wheel zooming."""
1380 delta
= qtcompat
.wheel_delta(event
)
1381 zoom
= math
.pow(2.0, delta
/512.0)
1382 factor
= (self
.transform()
1384 .mapRect(QtCore
.QRectF(0.0, 0.0, 1.0, 1.0))
1386 if factor
< 0.014 or factor
> 42.0:
1388 self
.setTransformationAnchor(QtWidgets
.QGraphicsView
.AnchorUnderMouse
)
1390 self
.scale(zoom
, zoom
)
1392 def wheel_pan(self
, event
):
1393 """Handle mouse wheel panning."""
1394 unit
= QtCore
.QRectF(0.0, 0.0, 1.0, 1.0)
1395 factor
= 1.0 / self
.transform().mapRect(unit
).width()
1396 tx
, ty
= qtcompat
.wheel_translation(event
)
1398 matrix
= self
.transform().translate(tx
* factor
, ty
* factor
)
1399 self
.setTransformationAnchor(QtWidgets
.QGraphicsView
.NoAnchor
)
1400 self
.setTransform(matrix
)
1402 def scale_view(self
, scale
):
1403 factor
= (self
.transform()
1404 .scale(scale
, scale
)
1405 .mapRect(QtCore
.QRectF(0, 0, 1, 1))
1407 if factor
< 0.07 or factor
> 100.0:
1411 adjust_scrollbars
= True
1412 scrollbar
= self
.verticalScrollBar()
1414 value
= scrollbar
.value()
1415 min_
= scrollbar
.minimum()
1416 max_
= scrollbar
.maximum()
1417 range_
= max_
- min_
1418 distance
= value
- min_
1419 nonzero_range
= range_
> 0.1
1421 scrolloffset
= distance
/range_
1423 adjust_scrollbars
= False
1425 self
.setTransformationAnchor(QtWidgets
.QGraphicsView
.NoAnchor
)
1426 self
.scale(scale
, scale
)
1428 scrollbar
= self
.verticalScrollBar()
1429 if scrollbar
and adjust_scrollbars
:
1430 min_
= scrollbar
.minimum()
1431 max_
= scrollbar
.maximum()
1432 range_
= max_
- min_
1433 value
= min_
+ int(float(range_
) * scrolloffset
)
1434 scrollbar
.setValue(value
)
1436 def add_commits(self
, commits
):
1437 """Traverse commits and add them to the view."""
1438 self
.commits
.extend(commits
)
1439 scene
= self
.scene()
1440 for commit
in commits
:
1441 item
= Commit(commit
, self
.notifier
)
1442 self
.items
[commit
.oid
] = item
1443 for ref
in commit
.tags
:
1444 self
.items
[ref
] = item
1447 self
.layout_commits()
1450 def link(self
, commits
):
1451 """Create edges linking commits with their parents"""
1452 scene
= self
.scene()
1453 for commit
in commits
:
1455 commit_item
= self
.items
[commit
.oid
]
1457 # TODO - Handle truncated history viewing
1459 for parent
in reversed(commit
.parents
):
1461 parent_item
= self
.items
[parent
.oid
]
1463 # TODO - Handle truncated history viewing
1465 edge
= Edge(parent_item
, commit_item
)
1468 def layout_commits(self
):
1469 positions
= self
.position_nodes()
1470 for oid
, (x
, y
) in positions
.items():
1471 item
= self
.items
[oid
]
1474 """Commit node layout technique
1476 Nodes are aligned by a mesh. Columns and rows are distributed using
1477 algorithms described below.
1479 Row assignment algorithm
1481 The algorithm aims consequent.
1482 1. A commit should be above all its parents.
1483 2. No commit should be at right side of a commit with a tag in same row.
1484 This prevents overlapping of tag labels with commits and other labels.
1485 3. Commit density should be maximized.
1487 The algorithm requires that all parents of a commit were assigned column.
1488 Nodes must be traversed in generation ascend order. This guarantees that all
1489 parents of a commit were assigned row. So, the algorithm may operate in course
1490 of column assignment algorithm.
1492 Row assignment uses frontier. A frontier is a dictionary that contains
1493 minimum available row index for each column. It propagates during the
1494 algorithm. Set of cells with tags is also maintained to meet second aim.
1496 Initialization is performed by reset_rows method. Each new column should
1497 be declared using declare_column method. Getting row for a cell is implemented
1498 in alloc_cell method. Frontier must be propagated for any child of fork
1499 commit which occupies different column. This meets first aim.
1501 Column assignment algorithm
1503 The algorithm traverses nodes in generation ascend order. This guarantees
1504 that a node will be visited after all its parents.
1506 The set of occupied columns are maintained during work. Initially it is
1507 empty and no node occupied a column. Empty columns are selected by request in
1508 index ascend order starting from 0. Each column has its reference counter.
1509 Being allocated a column is assigned 1 reference. When a counter reaches 0 the
1510 column is removed from occupied column set. Currently no counter becomes
1511 gather than 1, but leave_column method is written in generic way.
1513 Initialization is performed by reset_columns method. Column allocation is
1514 implemented in alloc_column method. Initialization and main loop are in
1515 recompute_columns method. Main loop also embeds row assignment algorithm by
1516 implementation. So, the algorithm initialization is also performed during
1517 recompute_grid method by calling reset_rows.
1519 Actions for each node are follow.
1520 1. If the node was not assigned a column then it is assigned empty one.
1521 2. Handle columns occupied by parents. Handling is leaving columns of some
1522 parents. One of parents occupies same column as the node. The column should not
1523 be left. Hence if the node is not a merge then nothing is done during the step.
1524 Other parents of merge node are processed in follow way.
1525 2.1. If parent is fork then a brother node could be at column of the
1526 parent. So, the column cannot be left. Note that the brother itself or one of
1527 its descendant will perform the column leaving at appropriate time.
1528 2.2 The parent may not occupy a column. This is possible when some commits
1529 were not added to the DAG (during repository reading, for instance). No column
1531 2.3. Leave column of the parent. The parent is a regular commit. Its
1532 outgoing edge is turned form its column to column of the node. Hence, the
1534 3. Get row for the node.
1535 4. Define columns and rows of children.
1536 4.1 If a child have a column assigned then it should no be overridden. One
1537 of children is assigned same column as the node. If the node is a fork then the
1538 child is chosen in generation descent order. This is a heuristic and it only
1539 affects resulting appearance of the graph. Other children are assigned empty
1540 columns in same order. It is the heuristic too.
1541 4.2 All children will got row during step 3 of its iteration. But frontier
1542 must be propagated during this iteration to meet first aim of the row
1543 assignment algorithm. Frontier of child that occupies same row was propagated
1544 during step 3. Hence, it must be propagated for children on side columns.
1546 After the algorithm was done all commit graphic items are assigned
1547 coordinates based on its row and column multiplied by the coefficient.
1550 def reset_columns(self
):
1551 for node
in self
.commits
:
1555 def reset_rows(self
):
1557 self
.tagged_cells
= set()
1559 def declare_column(self
, column
):
1561 # This is heuristic that mostly affects roots. Note that the
1562 # frontier values for fork children will be overridden in course of
1563 # propagate_frontier.
1564 self
.frontier
[column
] = self
.frontier
[column
- 1] - 1
1566 # First commit must be assigned 0 row.
1567 self
.frontier
[column
] = 0
1569 def alloc_column(self
):
1570 columns
= self
.columns
1572 if c
not in columns
:
1574 self
.declare_column(c
)
1578 def alloc_cell(self
, column
, tags
):
1579 # Get empty cell from frontier.
1580 cell_row
= self
.frontier
[column
]
1583 # Prevent overlapping with right cells. Do not occupy row if the
1584 # row is occupied by a commit at right side.
1585 for c
in range(column
+ 1, len(self
.frontier
)):
1586 frontier
= self
.frontier
[c
]
1587 if frontier
> cell_row
:
1590 # Avoid overlapping with tags of left cells.
1591 # Sorting is a part for column overlapping check optimization.
1592 columns
= sorted(range(0, column
), key
=lambda c
: self
.frontier
[c
])
1594 # Optimization. Remove columns which cannot contain overlapping
1595 # tags because all its commits are below.
1598 if self
.frontier
[c
] <= cell_row
:
1599 # The column cannot overlap.
1602 # This column may overlap because the frontier is above.
1603 # Consequent columns may overlap too because columns
1608 if (c
, cell_row
) in self
.tagged_cells
:
1609 # Overlapping. Try next row.
1613 # No overlapping was found.
1615 # Note that all checks should be made for new cell_row value.
1618 self
.tagged_cells
.add((column
, cell_row
))
1620 # Propagate frontier.
1621 self
.frontier
[column
] = cell_row
+ 1
1624 def propagate_frontier(self
, column
, value
):
1625 current
= self
.frontier
[column
]
1627 self
.frontier
[column
] = value
1629 def leave_column(self
, column
):
1630 count
= self
.columns
[column
]
1632 del self
.columns
[column
]
1634 self
.columns
[column
] = count
- 1
1636 def recompute_columns(self
):
1637 self
.reset_columns()
1640 for node
in self
.sort_by_generation(list(self
.commits
)):
1641 if node
.column
is None:
1642 # Node is either root or its parent is not in items. The last
1643 # happens when tree loading is in progress. Allocate new
1644 # columns for such nodes.
1645 node
.column
= self
.alloc_column()
1648 for parent
in node
.parents
:
1649 if parent
.is_fork():
1651 if parent
.column
== node
.column
:
1653 if parent
.column
is None:
1654 # Parent is in not among commits being layoutted, so it
1657 self
.leave_column(parent
.column
)
1659 node
.row
= self
.alloc_cell(node
.column
, node
.tags
)
1661 # Propagate column to children which are still without one. Also
1662 # propagate frontier for children.
1664 sorted_children
= sorted(node
.children
,
1665 key
=lambda c
: c
.generation
,
1667 citer
= iter(sorted_children
)
1669 if child
.column
is None:
1670 # Top most child occupies column of parent.
1671 child
.column
= node
.column
1672 # Note that frontier is propagated in course of
1676 self
.propagate_frontier(child
.column
, node
.row
+ 1)
1678 # Rest children are allocated new column.
1680 if child
.column
is None:
1681 child
.column
= self
.alloc_column()
1682 self
.propagate_frontier(child
.column
, node
.row
+ 1)
1684 child
= node
.children
[0]
1685 if child
.column
is None:
1686 child
.column
= node
.column
1687 # Note that frontier is propagated in course of alloc_cell.
1688 elif child
.column
!= node
.column
:
1689 # Child node have other parents and occupies side column.
1690 self
.propagate_frontier(child
.column
, node
.row
+ 1)
1692 def position_nodes(self
):
1693 self
.recompute_columns()
1703 for node
in self
.commits
:
1704 x_pos
= x_min
+ node
.column
* x_off
1705 y_pos
= y_off
+ node
.row
* y_off
1707 positions
[node
.oid
] = (x_pos
, y_pos
)
1709 x_max
= max(x_max
, x_pos
)
1710 y_min
= min(y_min
, y_pos
)
1717 def update_scene_rect(self
):
1720 self
.scene().setSceneRect(-GraphView
.x_adjust
,
1721 y_min
-GraphView
.y_adjust
,
1722 x_max
+ GraphView
.x_adjust
,
1723 abs(y_min
) + GraphView
.y_adjust
)
1725 def sort_by_generation(self
, commits
):
1726 if len(commits
) < 2:
1728 commits
.sort(key
=lambda x
: x
.generation
)
1732 def contextMenuEvent(self
, event
):
1733 self
.context_menu_event(event
)
1735 def mousePressEvent(self
, event
):
1736 if event
.button() == Qt
.MidButton
:
1738 self
.mouse_start
= [pos
.x(), pos
.y()]
1739 self
.saved_matrix
= self
.transform()
1740 self
.is_panning
= True
1742 if event
.button() == Qt
.RightButton
:
1745 if event
.button() == Qt
.LeftButton
:
1747 self
.handle_event(QtWidgets
.QGraphicsView
.mousePressEvent
, event
)
1749 def mouseMoveEvent(self
, event
):
1750 pos
= self
.mapToScene(event
.pos())
1754 self
.last_mouse
[0] = pos
.x()
1755 self
.last_mouse
[1] = pos
.y()
1756 self
.handle_event(QtWidgets
.QGraphicsView
.mouseMoveEvent
, event
)
1758 self
.viewport().repaint()
1760 def mouseReleaseEvent(self
, event
):
1761 self
.pressed
= False
1762 if event
.button() == Qt
.MidButton
:
1763 self
.is_panning
= False
1765 self
.handle_event(QtWidgets
.QGraphicsView
.mouseReleaseEvent
, event
)
1766 self
.selection_list
= []
1767 self
.viewport().repaint()
1769 def wheelEvent(self
, event
):
1770 """Handle Qt mouse wheel events."""
1771 if event
.modifiers() & Qt
.ControlModifier
:
1772 self
.wheel_zoom(event
)
1774 self
.wheel_pan(event
)
1779 # oid -- Git objects IDs (i.e. SHA-1 IDs)
1780 # ref -- Git references that resolve to a commit-ish (HEAD, branches, tags)