1 from __future__
import division
, absolute_import
, unicode_literals
6 from PyQt4
import QtGui
7 from PyQt4
import QtCore
8 from PyQt4
.QtCore
import Qt
9 from PyQt4
.QtCore
import SIGNAL
10 from PyQt4
.QtCore
import QPointF
11 from PyQt4
.QtCore
import QRectF
15 from cola
import difftool
16 from cola
import hotkeys
17 from cola
import icons
18 from cola
import observable
19 from cola
import qtutils
20 from cola
.i18n
import N_
21 from cola
.models
import dag
22 from cola
.widgets
import archive
23 from cola
.widgets
import browse
24 from cola
.widgets
import completion
25 from cola
.widgets
import createbranch
26 from cola
.widgets
import createtag
27 from cola
.widgets
import defs
28 from cola
.widgets
import diff
29 from cola
.widgets
import filelist
30 from cola
.widgets
import standard
31 from cola
.compat
import ustr
34 def git_dag(model
, args
=None, settings
=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 view
= GitDAG(model
, ctx
, settings
=settings
)
48 class ViewerMixin(object):
49 """Implementations must provide selected_items()"""
54 self
.menu_actions
= self
.context_menu_actions()
56 def selected_item(self
):
57 """Return the currently selected item"""
58 selected_items
= self
.selected_items()
59 if not selected_items
:
61 return selected_items
[0]
63 def selected_sha1(self
):
64 item
= self
.selected_item()
67 return item
.commit
.sha1
69 def selected_sha1s(self
):
70 return [i
.commit
for i
in self
.selected_items()]
72 def diff_selected_this(self
):
73 clicked_sha1
= self
.clicked
.sha1
74 selected_sha1
= self
.selected
.sha1
75 self
.emit(SIGNAL('diff_commits(PyQt_PyObject,PyQt_PyObject)'),
76 selected_sha1
, clicked_sha1
)
78 def diff_this_selected(self
):
79 clicked_sha1
= self
.clicked
.sha1
80 selected_sha1
= self
.selected
.sha1
81 self
.emit(SIGNAL('diff_commits(PyQt_PyObject,PyQt_PyObject)'),
82 clicked_sha1
, selected_sha1
)
84 def cherry_pick(self
):
85 sha1
= self
.selected_sha1()
88 cmds
.do(cmds
.CherryPick
, [sha1
])
90 def copy_to_clipboard(self
):
91 sha1
= self
.selected_sha1()
94 qtutils
.set_clipboard(sha1
)
96 def create_branch(self
):
97 sha1
= self
.selected_sha1()
100 createbranch
.create_new_branch(revision
=sha1
)
102 def create_tag(self
):
103 sha1
= self
.selected_sha1()
106 createtag
.create_tag(ref
=sha1
)
108 def create_tarball(self
):
109 sha1
= self
.selected_sha1()
112 short_sha1
= sha1
[:7]
113 archive
.GitArchiveDialog
.save_hashed_objects(sha1
, short_sha1
, self
)
115 def save_blob_dialog(self
):
116 sha1
= self
.selected_sha1()
119 return browse
.BrowseDialog
.browse(sha1
)
121 def context_menu_actions(self
):
123 'diff_this_selected':
124 qtutils
.add_action(self
, N_('Diff this -> selected'),
125 self
.diff_this_selected
),
126 'diff_selected_this':
127 qtutils
.add_action(self
, N_('Diff selected -> this'),
128 self
.diff_selected_this
),
130 qtutils
.add_action(self
, N_('Create Branch'),
133 qtutils
.add_action(self
, N_('Create Patch'),
136 qtutils
.add_action(self
, N_('Create Tag'),
139 qtutils
.add_action(self
, N_('Save As Tarball/Zip...'),
140 self
.create_tarball
),
142 qtutils
.add_action(self
, N_('Cherry Pick'),
145 qtutils
.add_action(self
, N_('Grab File...'),
146 self
.save_blob_dialog
),
148 qtutils
.add_action(self
, N_('Copy SHA-1'),
149 self
.copy_to_clipboard
,
150 QtGui
.QKeySequence
.Copy
),
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
)
174 self
.menu_actions
['create_branch'].setEnabled(has_single_selection
)
175 self
.menu_actions
['create_tag'].setEnabled(has_single_selection
)
177 self
.menu_actions
['cherry_pick'].setEnabled(has_single_selection
)
178 self
.menu_actions
['create_patch'].setEnabled(has_selection
)
179 self
.menu_actions
['create_tarball'].setEnabled(has_single_selection
)
181 self
.menu_actions
['save_blob'].setEnabled(has_single_selection
)
182 self
.menu_actions
['copy'].setEnabled(has_single_selection
)
184 def context_menu_event(self
, event
):
185 self
.update_menu_actions(event
)
186 menu
= QtGui
.QMenu(self
)
187 menu
.addAction(self
.menu_actions
['diff_this_selected'])
188 menu
.addAction(self
.menu_actions
['diff_selected_this'])
190 menu
.addAction(self
.menu_actions
['create_branch'])
191 menu
.addAction(self
.menu_actions
['create_tag'])
193 menu
.addAction(self
.menu_actions
['cherry_pick'])
194 menu
.addAction(self
.menu_actions
['create_patch'])
195 menu
.addAction(self
.menu_actions
['create_tarball'])
197 menu
.addAction(self
.menu_actions
['save_blob'])
198 menu
.addAction(self
.menu_actions
['copy'])
199 menu
.exec_(self
.mapToGlobal(event
.pos()))
202 class CommitTreeWidgetItem(QtGui
.QTreeWidgetItem
):
204 def __init__(self
, commit
, parent
=None):
205 QtGui
.QTreeWidgetItem
.__init
__(self
, parent
)
207 self
.setText(0, commit
.summary
)
208 self
.setText(1, commit
.author
)
209 self
.setText(2, commit
.authdate
)
212 class CommitTreeWidget(ViewerMixin
, standard
.TreeWidget
):
214 def __init__(self
, notifier
, parent
):
215 standard
.TreeWidget
.__init
__(self
, parent
)
216 ViewerMixin
.__init
__(self
)
218 self
.setSelectionMode(self
.ExtendedSelection
)
219 self
.setHeaderLabels([N_('Summary'), N_('Author'), N_('Date, Time')])
222 self
.notifier
= notifier
223 self
.selecting
= False
226 self
.action_up
= qtutils
.add_action(self
, N_('Go Up'),
227 self
.go_up
, hotkeys
.MOVE_UP
)
229 self
.action_down
= qtutils
.add_action(self
, N_('Go Down'),
230 self
.go_down
, hotkeys
.MOVE_DOWN
)
232 notifier
.add_observer(diff
.COMMITS_SELECTED
, self
.commits_selected
)
234 self
.connect(self
, SIGNAL('itemSelectionChanged()'),
235 self
.selection_changed
)
239 self
.goto(self
.itemAbove
)
242 self
.goto(self
.itemBelow
)
244 def goto(self
, finder
):
245 items
= self
.selected_items()
246 item
= items
and items
[0] or None
251 self
.select([found
.commit
.sha1
])
253 def selected_commit_range(self
):
254 selected_items
= self
.selected_items()
255 if not selected_items
:
257 return selected_items
[-1].commit
.sha1
, selected_items
[0].commit
.sha1
259 def set_selecting(self
, selecting
):
260 self
.selecting
= selecting
262 def selection_changed(self
):
263 items
= self
.selected_items()
266 self
.set_selecting(True)
267 self
.notifier
.notify_observers(diff
.COMMITS_SELECTED
,
268 [i
.commit
for i
in items
])
269 self
.set_selecting(False)
271 def commits_selected(self
, commits
):
274 with qtutils
.BlockSignals(self
):
275 self
.select([commit
.sha1
for commit
in commits
])
277 def select(self
, sha1s
):
280 self
.clearSelection()
281 for idx
, sha1
in enumerate(sha1s
):
283 item
= self
.sha1map
[sha1
]
286 self
.scrollToItem(item
)
287 item
.setSelected(True)
289 def adjust_columns(self
):
290 width
= self
.width()-20
293 self
.setColumnWidth(0, zero
)
294 self
.setColumnWidth(1, onetwo
)
295 self
.setColumnWidth(2, onetwo
)
298 QtGui
.QTreeWidget
.clear(self
)
302 def add_commits(self
, commits
):
303 self
.commits
.extend(commits
)
305 for c
in reversed(commits
):
306 item
= CommitTreeWidgetItem(c
)
308 self
.sha1map
[c
.sha1
] = item
310 self
.sha1map
[tag
] = item
311 self
.insertTopLevelItems(0, items
)
313 def create_patch(self
):
314 items
= self
.selectedItems()
317 sha1s
= [item
.commit
.sha1
for item
in reversed(items
)]
318 all_sha1s
= [c
.sha1
for c
in self
.commits
]
319 cmds
.do(cmds
.FormatPatch
, sha1s
, all_sha1s
)
322 def contextMenuEvent(self
, event
):
323 self
.context_menu_event(event
)
325 def mousePressEvent(self
, event
):
326 if event
.button() == Qt
.RightButton
:
329 QtGui
.QTreeWidget
.mousePressEvent(self
, event
)
332 class GitDAG(standard
.MainWindow
):
333 """The git-dag widget."""
335 def __init__(self
, model
, ctx
, parent
=None, settings
=None):
336 standard
.MainWindow
.__init
__(self
, parent
)
338 self
.setAttribute(Qt
.WA_MacMetalStyle
)
339 self
.setMinimumSize(420, 420)
341 # change when widgets are added/removed
342 self
.widget_version
= 2
345 self
.settings
= settings
348 self
.commit_list
= []
351 self
.thread
= ReaderThread(ctx
, self
)
352 self
.revtext
= completion
.GitLogLineEdit()
353 self
.maxresults
= standard
.SpinBox()
355 self
.zoom_out
= qtutils
.create_action_button(
356 tooltip
=N_('Zoom Out'), icon
=icons
.zoom_out())
358 self
.zoom_in
= qtutils
.create_action_button(
359 tooltip
=N_('Zoom In'), icon
=icons
.zoom_in())
361 self
.zoom_to_fit
= qtutils
.create_action_button(
362 tooltip
=N_('Zoom to Fit'), icon
=icons
.zoom_fit_best())
364 self
.notifier
= notifier
= observable
.Observable()
365 self
.notifier
.refs_updated
= refs_updated
= 'refs_updated'
366 self
.notifier
.add_observer(refs_updated
, self
.display
)
367 self
.notifier
.add_observer(filelist
.HISTORIES_SELECTED
,
368 self
.histories_selected
)
369 self
.notifier
.add_observer(filelist
.DIFFTOOL_SELECTED
,
370 self
.difftool_selected
)
371 self
.notifier
.add_observer(diff
.COMMITS_SELECTED
, self
.commits_selected
)
373 self
.treewidget
= CommitTreeWidget(notifier
, self
)
374 self
.diffwidget
= diff
.DiffWidget(notifier
, self
)
375 self
.filewidget
= filelist
.FileWidget(notifier
, self
)
376 self
.graphview
= GraphView(notifier
, self
)
378 self
.controls_layout
= qtutils
.hbox(defs
.no_margin
, defs
.spacing
,
379 self
.revtext
, self
.maxresults
)
381 self
.controls_widget
= QtGui
.QWidget()
382 self
.controls_widget
.setLayout(self
.controls_layout
)
384 self
.log_dock
= qtutils
.create_dock(N_('Log'), self
, stretch
=False)
385 self
.log_dock
.setWidget(self
.treewidget
)
386 log_dock_titlebar
= self
.log_dock
.titleBarWidget()
387 log_dock_titlebar
.add_corner_widget(self
.controls_widget
)
389 self
.file_dock
= qtutils
.create_dock(N_('Files'), self
)
390 self
.file_dock
.setWidget(self
.filewidget
)
392 self
.diff_dock
= qtutils
.create_dock(N_('Diff'), self
)
393 self
.diff_dock
.setWidget(self
.diffwidget
)
395 self
.graph_controls_layout
= qtutils
.hbox(
396 defs
.no_margin
, defs
.button_spacing
,
397 self
.zoom_out
, self
.zoom_in
, self
.zoom_to_fit
,
400 self
.graph_controls_widget
= QtGui
.QWidget()
401 self
.graph_controls_widget
.setLayout(self
.graph_controls_layout
)
403 self
.graphview_dock
= qtutils
.create_dock(N_('Graph'), self
)
404 self
.graphview_dock
.setWidget(self
.graphview
)
405 graph_titlebar
= self
.graphview_dock
.titleBarWidget()
406 graph_titlebar
.add_corner_widget(self
.graph_controls_widget
)
408 self
.lock_layout_action
= qtutils
.add_action_bool(self
,
409 N_('Lock Layout'), self
.set_lock_layout
, False)
411 self
.refresh_action
= qtutils
.add_action(self
,
412 N_('Refresh'), self
.refresh
, hotkeys
.REFRESH
)
414 # Create the application menu
415 self
.menubar
= QtGui
.QMenuBar(self
)
418 self
.view_menu
= qtutils
.create_menu(N_('View'), self
.menubar
)
419 self
.view_menu
.addAction(self
.refresh_action
)
421 self
.view_menu
.addAction(self
.log_dock
.toggleViewAction())
422 self
.view_menu
.addAction(self
.graphview_dock
.toggleViewAction())
423 self
.view_menu
.addAction(self
.diff_dock
.toggleViewAction())
424 self
.view_menu
.addAction(self
.file_dock
.toggleViewAction())
425 self
.view_menu
.addSeparator()
426 self
.view_menu
.addAction(self
.lock_layout_action
)
428 self
.menubar
.addAction(self
.view_menu
.menuAction())
429 self
.setMenuBar(self
.menubar
)
431 left
= Qt
.LeftDockWidgetArea
432 right
= Qt
.RightDockWidgetArea
433 self
.addDockWidget(left
, self
.log_dock
)
434 self
.addDockWidget(left
, self
.diff_dock
)
435 self
.addDockWidget(right
, self
.graphview_dock
)
436 self
.addDockWidget(right
, self
.file_dock
)
438 # Update fields affected by model
439 self
.revtext
.setText(ctx
.ref
)
440 self
.maxresults
.setValue(ctx
.count
)
441 self
.update_window_title()
443 # Also re-loads dag.* from the saved state
444 if not self
.restore_state(settings
=settings
):
445 self
.resize_to_desktop()
447 qtutils
.connect_button(self
.zoom_out
, self
.graphview
.zoom_out
)
448 qtutils
.connect_button(self
.zoom_in
, self
.graphview
.zoom_in
)
449 qtutils
.connect_button(self
.zoom_to_fit
,
450 self
.graphview
.zoom_to_fit
)
452 self
.thread
.connect(self
.thread
, self
.thread
.begin
, self
.thread_begin
,
454 self
.thread
.connect(self
.thread
, self
.thread
.status
, self
.thread_status
,
456 self
.thread
.connect(self
.thread
, self
.thread
.add
, self
.add_commits
,
458 self
.thread
.connect(self
.thread
, self
.thread
.end
, self
.thread_end
,
461 self
.connect(self
.treewidget
,
462 SIGNAL('diff_commits(PyQt_PyObject,PyQt_PyObject)'),
465 self
.connect(self
.graphview
,
466 SIGNAL('diff_commits(PyQt_PyObject,PyQt_PyObject)'),
469 self
.connect(self
.maxresults
, SIGNAL('editingFinished()'),
472 self
.connect(self
.revtext
, SIGNAL('textChanged(QString)'),
475 self
.connect(self
.revtext
, SIGNAL('activated()'), self
.display
)
476 self
.connect(self
.revtext
, SIGNAL('return()'), self
.display
)
477 self
.connect(self
.revtext
, SIGNAL('down()'), self
.focus_tree
)
479 # The model is updated in another thread so use
480 # signals/slots to bring control back to the main GUI thread
481 self
.model
.add_observer(self
.model
.message_updated
,
482 self
.emit_model_updated
)
484 self
.connect(self
, SIGNAL('model_updated()'), self
.model_updated
,
487 qtutils
.add_action(self
, 'Focus Input', self
.focus_input
, hotkeys
.FOCUS
)
488 qtutils
.add_close_action(self
)
490 def focus_input(self
):
491 self
.revtext
.setFocus()
493 def focus_tree(self
):
494 self
.treewidget
.setFocus()
496 def text_changed(self
, txt
):
497 self
.ctx
.ref
= ustr(txt
)
498 self
.update_window_title()
500 def update_window_title(self
):
501 project
= self
.model
.project
503 self
.setWindowTitle(N_('%(project)s: %(ref)s - DAG')
504 % dict(project
=project
, ref
=self
.ctx
.ref
))
506 self
.setWindowTitle(project
+ N_(' - DAG'))
508 def export_state(self
):
509 state
= standard
.MainWindow
.export_state(self
)
510 state
['count'] = self
.ctx
.count
513 def apply_state(self
, state
):
514 result
= standard
.MainWindow
.apply_state(self
, state
)
516 count
= state
['count']
517 if self
.ctx
.overridden('count'):
518 count
= self
.ctx
.count
520 count
= self
.ctx
.count
522 self
.ctx
.set_count(count
)
523 self
.lock_layout_action
.setChecked(state
.get('lock_layout', False))
526 def emit_model_updated(self
):
527 self
.emit(SIGNAL('model_updated()'))
529 def model_updated(self
):
533 cmds
.do(cmds
.Refresh
)
536 new_ref
= self
.revtext
.value()
537 new_count
= self
.maxresults
.value()
540 self
.ctx
.set_ref(new_ref
)
541 self
.ctx
.set_count(new_count
)
545 standard
.MainWindow
.show(self
)
546 self
.treewidget
.adjust_columns()
548 def commits_selected(self
, commits
):
550 self
.selection
= commits
554 self
.commit_list
= []
555 self
.graphview
.clear()
556 self
.treewidget
.clear()
558 def add_commits(self
, commits
):
559 self
.commit_list
.extend(commits
)
560 # Keep track of commits
561 for commit_obj
in commits
:
562 self
.commits
[commit_obj
.sha1
] = commit_obj
563 for tag
in commit_obj
.tags
:
564 self
.commits
[tag
] = commit_obj
565 self
.graphview
.add_commits(commits
)
566 self
.treewidget
.add_commits(commits
)
568 def thread_begin(self
):
571 def thread_end(self
):
573 self
.restore_selection()
575 def thread_status(self
, successful
):
576 self
.revtext
.hint
.set_error(not successful
)
578 def restore_selection(self
):
579 selection
= self
.selection
581 commit_obj
= self
.commit_list
[-1]
583 # No commits, exist, early-out
586 new_commits
= [self
.commits
.get(s
.sha1
, None) for s
in selection
]
587 new_commits
= [c
for c
in new_commits
if c
is not None]
589 # The old selection exists in the new state
590 self
.notifier
.notify_observers(diff
.COMMITS_SELECTED
, new_commits
)
592 # The old selection is now empty. Select the top-most commit
593 self
.notifier
.notify_observers(diff
.COMMITS_SELECTED
, [commit_obj
])
595 self
.graphview
.update_scene_rect()
596 self
.graphview
.set_initial_view()
598 def resize_to_desktop(self
):
599 desktop
= QtGui
.QApplication
.instance().desktop()
600 width
= desktop
.width()
601 height
= desktop
.height()
602 self
.resize(width
, height
)
604 def diff_commits(self
, a
, b
):
605 paths
= self
.ctx
.paths()
607 difftool
.launch(left
=a
, right
=b
, paths
=paths
)
609 difftool
.diff_commits(self
, a
, b
)
612 def closeEvent(self
, event
):
613 self
.revtext
.close_popup()
615 standard
.MainWindow
.closeEvent(self
, event
)
617 def resizeEvent(self
, e
):
618 standard
.MainWindow
.resizeEvent(self
, e
)
619 self
.treewidget
.adjust_columns()
621 def histories_selected(self
, histories
):
622 argv
= [self
.model
.currentbranch
, '--']
623 argv
.extend(histories
)
624 text
= core
.list2cmdline(argv
)
625 self
.revtext
.setText(text
)
628 def difftool_selected(self
, files
):
629 bottom
, top
= self
.treewidget
.selected_commit_range()
632 difftool
.launch(left
=bottom
, left_take_parent
=True,
633 right
=top
, paths
=files
)
636 class ReaderThread(QtCore
.QThread
):
637 begin
= SIGNAL('begin')
640 status
= SIGNAL('status')
642 def __init__(self
, ctx
, parent
):
643 QtCore
.QThread
.__init
__(self
, parent
)
647 self
._mutex
= QtCore
.QMutex()
648 self
._condition
= QtCore
.QWaitCondition()
651 repo
= dag
.RepoReader(self
.ctx
)
653 self
.emit(self
.begin
)
658 self
._condition
.wait(self
._mutex
)
664 if len(commits
) >= 512:
665 self
.emit(self
.add
, commits
)
668 self
.emit(self
.status
, repo
.returncode
== 0)
670 self
.emit(self
.add
, commits
)
676 QtCore
.QThread
.start(self
)
687 self
._condition
.wakeOne()
698 class Edge(QtGui
.QGraphicsItem
):
699 item_type
= QtGui
.QGraphicsItem
.UserType
+ 1
701 def __init__(self
, source
, dest
):
703 QtGui
.QGraphicsItem
.__init
__(self
)
705 self
.setAcceptedMouseButtons(Qt
.NoButton
)
708 self
.commit
= source
.commit
711 dest_pt
= Commit
.item_bbox
.center()
713 self
.source_pt
= self
.mapFromItem(self
.source
, dest_pt
)
714 self
.dest_pt
= self
.mapFromItem(self
.dest
, dest_pt
)
715 self
.line
= QtCore
.QLineF(self
.source_pt
, self
.dest_pt
)
717 width
= self
.dest_pt
.x() - self
.source_pt
.x()
718 height
= self
.dest_pt
.y() - self
.source_pt
.y()
719 rect
= QtCore
.QRectF(self
.source_pt
, QtCore
.QSizeF(width
, height
))
720 self
.bound
= rect
.normalized()
722 # Choose a new color for new branch edges
723 if self
.source
.x() < self
.dest
.x():
724 color
= EdgeColor
.next()
726 elif self
.source
.x() != self
.dest
.x():
727 color
= EdgeColor
.current()
730 color
= EdgeColor
.current()
733 self
.pen
= QtGui
.QPen(color
, 4.0, line
, Qt
.SquareCap
, Qt
.RoundJoin
)
737 return self
.item_type
739 def boundingRect(self
):
742 def paint(self
, painter
, option
, widget
):
747 painter
.setPen(self
.pen
)
748 path
= QtGui
.QPainterPath()
750 if self
.source
.x() == self
.dest
.x():
751 path
.moveTo(self
.source
.x(), self
.source
.y())
752 path
.lineTo(self
.dest
.x(), self
.dest
.y())
753 painter
.drawPath(path
)
757 #Define points starting from source
758 point1
= QPointF(self
.source
.x(), self
.source
.y())
759 point2
= QPointF(point1
.x(), point1
.y() - connector_length
)
760 point3
= QPointF(point2
.x() + arc_rect
, point2
.y() - arc_rect
)
762 #Define points starting from dest
763 point4
= QPointF(self
.dest
.x(), self
.dest
.y())
764 point5
= QPointF(point4
.x(),point3
.y() - arc_rect
)
765 point6
= QPointF(point5
.x() - arc_rect
, point5
.y() + arc_rect
)
767 start_angle_arc1
= 180
769 start_angle_arc2
= 90
770 span_angle_arc2
= -90
772 # If the dest is at the left of the source, then we
773 # need to reverse some values
774 if self
.source
.x() > self
.dest
.x():
775 point5
= QPointF(point4
.x(), point4
.y() + connector_length
)
776 point6
= QPointF(point5
.x() + arc_rect
, point5
.y() + arc_rect
)
777 point3
= QPointF(self
.source
.x() - arc_rect
, point6
.y())
778 point2
= QPointF(self
.source
.x(), point3
.y() + arc_rect
)
784 path
.arcTo(QRectF(point2
, point3
),
785 start_angle_arc1
, span_angle_arc1
)
787 path
.arcTo(QRectF(point6
, point5
),
788 start_angle_arc2
, span_angle_arc2
)
790 painter
.drawPath(path
)
793 class EdgeColor(object):
794 """An edge color factory"""
796 current_color_index
= 0
798 QtGui
.QColor(Qt
.red
),
799 QtGui
.QColor(Qt
.green
),
800 QtGui
.QColor(Qt
.blue
),
801 QtGui
.QColor(Qt
.black
),
802 QtGui
.QColor(Qt
.darkRed
),
803 QtGui
.QColor(Qt
.darkGreen
),
804 QtGui
.QColor(Qt
.darkBlue
),
805 QtGui
.QColor(Qt
.cyan
),
806 QtGui
.QColor(Qt
.magenta
),
807 # Orange; Qt.yellow is too low-contrast
808 qtutils
.rgba(0xff, 0x66, 0x00),
809 QtGui
.QColor(Qt
.gray
),
810 QtGui
.QColor(Qt
.darkCyan
),
811 QtGui
.QColor(Qt
.darkMagenta
),
812 QtGui
.QColor(Qt
.darkYellow
),
813 QtGui
.QColor(Qt
.darkGray
),
818 cls
.current_color_index
+= 1
819 cls
.current_color_index
%= len(cls
.colors
)
820 color
= cls
.colors
[cls
.current_color_index
]
826 return cls
.colors
[cls
.current_color_index
]
830 cls
.current_color_index
= 0
833 class Commit(QtGui
.QGraphicsItem
):
834 item_type
= QtGui
.QGraphicsItem
.UserType
+ 2
838 item_shape
= QtGui
.QPainterPath()
839 item_shape
.addRect(commit_radius
/-2.0,
841 commit_radius
, commit_radius
)
842 item_bbox
= item_shape
.boundingRect()
844 inner_rect
= QtGui
.QPainterPath()
845 inner_rect
.addRect(commit_radius
/-2.0 + 2.0,
846 commit_radius
/-2.0 + 2.0,
849 inner_rect
= inner_rect
.boundingRect()
851 commit_color
= QtGui
.QColor(Qt
.white
)
852 outline_color
= commit_color
.darker()
853 merge_color
= QtGui
.QColor(Qt
.lightGray
)
855 commit_selected_color
= QtGui
.QColor(Qt
.green
)
856 selected_outline_color
= commit_selected_color
.darker()
858 commit_pen
= QtGui
.QPen()
859 commit_pen
.setWidth(1.0)
860 commit_pen
.setColor(outline_color
)
862 def __init__(self
, commit
,
864 selectable
=QtGui
.QGraphicsItem
.ItemIsSelectable
,
865 cursor
=Qt
.PointingHandCursor
,
866 xpos
=commit_radius
/2.0 + 1.0,
867 cached_commit_color
=commit_color
,
868 cached_merge_color
=merge_color
):
870 QtGui
.QGraphicsItem
.__init
__(self
)
873 self
.notifier
= notifier
876 self
.setFlag(selectable
)
877 self
.setCursor(cursor
)
878 self
.setToolTip(commit
.sha1
[:7] + ': ' + commit
.summary
)
881 self
.label
= label
= Label(commit
)
882 label
.setParentItem(self
)
883 label
.setPos(xpos
, -self
.commit_radius
/2.0)
887 if len(commit
.parents
) > 1:
888 self
.brush
= cached_merge_color
890 self
.brush
= cached_commit_color
895 def blockSignals(self
, blocked
):
896 self
.notifier
.notification_enabled
= not blocked
898 def itemChange(self
, change
, value
):
899 if change
== QtGui
.QGraphicsItem
.ItemSelectedHasChanged
:
900 # Broadcast selection to other widgets
901 selected_items
= self
.scene().selectedItems()
902 commits
= [item
.commit
for item
in selected_items
]
903 self
.scene().parent().set_selecting(True)
904 self
.notifier
.notify_observers(diff
.COMMITS_SELECTED
, commits
)
905 self
.scene().parent().set_selecting(False)
907 # Cache the pen for use in paint()
908 if value
.toPyObject():
909 self
.brush
= self
.commit_selected_color
910 color
= self
.selected_outline_color
912 if len(self
.commit
.parents
) > 1:
913 self
.brush
= self
.merge_color
915 self
.brush
= self
.commit_color
916 color
= self
.outline_color
917 commit_pen
= QtGui
.QPen()
918 commit_pen
.setWidth(1.0)
919 commit_pen
.setColor(color
)
920 self
.commit_pen
= commit_pen
922 return QtGui
.QGraphicsItem
.itemChange(self
, change
, value
)
925 return self
.item_type
927 def boundingRect(self
, rect
=item_bbox
):
931 return self
.item_shape
933 def paint(self
, painter
, option
, widget
,
937 # Do not draw outside the exposed rect
938 painter
.setClipRect(option
.exposedRect
)
941 painter
.setPen(self
.commit_pen
)
942 painter
.setBrush(self
.brush
)
943 painter
.drawEllipse(inner
)
946 def mousePressEvent(self
, event
):
947 QtGui
.QGraphicsItem
.mousePressEvent(self
, event
)
949 self
.selected
= self
.isSelected()
951 def mouseMoveEvent(self
, event
):
954 QtGui
.QGraphicsItem
.mouseMoveEvent(self
, event
)
956 def mouseReleaseEvent(self
, event
):
957 QtGui
.QGraphicsItem
.mouseReleaseEvent(self
, event
)
958 if (not self
.dragged
and
960 event
.button() == Qt
.LeftButton
):
966 class Label(QtGui
.QGraphicsItem
):
967 item_type
= QtGui
.QGraphicsItem
.UserType
+ 3
972 item_shape
= QtGui
.QPainterPath()
973 item_shape
.addRect(0, 0, width
, height
)
974 item_bbox
= item_shape
.boundingRect()
976 text_options
= QtGui
.QTextOption()
977 text_options
.setAlignment(Qt
.AlignCenter
)
978 text_options
.setAlignment(Qt
.AlignVCenter
)
980 def __init__(self
, commit
,
981 other_color
=QtGui
.QColor(Qt
.white
),
982 head_color
=QtGui
.QColor(Qt
.green
)):
983 QtGui
.QGraphicsItem
.__init
__(self
)
986 # Starts with enough space for two tags. Any more and the commit
987 # needs to be taller to accommodate.
990 if 'HEAD' in commit
.tags
:
991 self
.color
= head_color
993 self
.color
= other_color
995 self
.color
.setAlpha(180)
996 self
.pen
= QtGui
.QPen()
997 self
.pen
.setColor(self
.color
.darker())
998 self
.pen
.setWidth(1.0)
1001 return self
.item_type
1003 def boundingRect(self
, rect
=item_bbox
):
1007 return self
.item_shape
1009 def paint(self
, painter
, option
, widget
,
1010 text_opts
=text_options
,
1014 font
= cache
.label_font
1015 except AttributeError:
1016 font
= cache
.label_font
= QtGui
.QApplication
.font()
1017 font
.setPointSize(6)
1021 painter
.setBrush(self
.color
)
1022 painter
.setPen(self
.pen
)
1023 painter
.setFont(font
)
1027 for tag
in self
.commit
.tags
:
1028 text_rect
= painter
.boundingRect(
1029 QRectF(current_width
, 0, 0, 0), Qt
.TextSingleLine
, tag
)
1030 box_rect
= text_rect
.adjusted(-1, -1, 1, 1)
1031 painter
.drawRoundedRect(box_rect
, 2, 2)
1032 painter
.drawText(text_rect
, Qt
.TextSingleLine
, tag
)
1033 current_width
+= text_rect
.width() + 5
1036 class GraphView(ViewerMixin
, QtGui
.QGraphicsView
):
1041 x_adjust
= Commit
.commit_radius
*4/3
1042 y_adjust
= Commit
.commit_radius
*4/3
1047 def __init__(self
, notifier
, parent
):
1048 QtGui
.QGraphicsView
.__init
__(self
, parent
)
1049 ViewerMixin
.__init
__(self
)
1051 highlight
= self
.palette().color(QtGui
.QPalette
.Highlight
)
1052 Commit
.commit_selected_color
= highlight
1053 Commit
.selected_outline_color
= highlight
.darker()
1055 self
.selection_list
= []
1056 self
.notifier
= notifier
1059 self
.saved_matrix
= QtGui
.QMatrix(self
.matrix())
1061 self
.x_offsets
= collections
.defaultdict(int)
1063 self
.is_panning
= False
1064 self
.pressed
= False
1065 self
.selecting
= False
1066 self
.last_mouse
= [0, 0]
1068 self
.setDragMode(self
.RubberBandDrag
)
1070 scene
= QtGui
.QGraphicsScene(self
)
1071 scene
.setItemIndexMethod(QtGui
.QGraphicsScene
.NoIndex
)
1072 self
.setScene(scene
)
1074 self
.setRenderHint(QtGui
.QPainter
.Antialiasing
)
1075 self
.setViewportUpdateMode(self
.BoundingRectViewportUpdate
)
1076 self
.setCacheMode(QtGui
.QGraphicsView
.CacheBackground
)
1077 self
.setTransformationAnchor(QtGui
.QGraphicsView
.AnchorUnderMouse
)
1078 self
.setResizeAnchor(QtGui
.QGraphicsView
.NoAnchor
)
1079 self
.setBackgroundBrush(QtGui
.QColor(Qt
.white
))
1081 qtutils
.add_action(self
, N_('Zoom In'), self
.zoom_in
,
1082 hotkeys
.ZOOM_IN
, hotkeys
.ZOOM_IN_SECONDARY
)
1084 qtutils
.add_action(self
, N_('Zoom Out'), self
.zoom_out
,
1087 qtutils
.add_action(self
, N_('Zoom to Fit'),
1088 self
.zoom_to_fit
, hotkeys
.FIT
)
1090 qtutils
.add_action(self
, N_('Select Parent'),
1091 self
.select_parent
, hotkeys
.MOVE_DOWN_TERTIARY
)
1093 qtutils
.add_action(self
, N_('Select Oldest Parent'),
1094 self
.select_oldest_parent
, hotkeys
.MOVE_DOWN
)
1096 qtutils
.add_action(self
, N_('Select Child'),
1097 self
.select_child
, hotkeys
.MOVE_UP_TERTIARY
)
1099 qtutils
.add_action(self
, N_('Select Newest Child'),
1100 self
.select_newest_child
, hotkeys
.MOVE_UP
)
1102 notifier
.add_observer(diff
.COMMITS_SELECTED
, self
.commits_selected
)
1106 self
.scene().clear()
1107 self
.selection_list
= []
1109 self
.x_offsets
.clear()
1114 # ViewerMixin interface
1115 def selected_items(self
):
1116 """Return the currently selected items"""
1117 return self
.scene().selectedItems()
1120 self
.scale_view(1.5)
1123 self
.scale_view(1.0/1.5)
1125 def commits_selected(self
, commits
):
1128 self
.select([commit
.sha1
for commit
in commits
])
1130 def select(self
, sha1s
):
1131 """Select the item for the SHA-1"""
1132 self
.scene().clearSelection()
1135 item
= self
.items
[sha1
]
1138 item
.blockSignals(True)
1139 item
.setSelected(True)
1140 item
.blockSignals(False)
1141 item_rect
= item
.sceneTransform().mapRect(item
.boundingRect())
1142 self
.ensureVisible(item_rect
)
1144 def get_item_by_generation(self
, commits
, criteria_fn
):
1145 """Return the item for the commit matching criteria"""
1149 for commit
in commits
:
1150 if (generation
is None or
1151 criteria_fn(generation
, commit
.generation
)):
1153 generation
= commit
.generation
1155 return self
.items
[sha1
]
1159 def oldest_item(self
, commits
):
1160 """Return the item for the commit with the oldest generation number"""
1161 return self
.get_item_by_generation(commits
, lambda a
, b
: a
> b
)
1163 def newest_item(self
, commits
):
1164 """Return the item for the commit with the newest generation number"""
1165 return self
.get_item_by_generation(commits
, lambda a
, b
: a
< b
)
1167 def create_patch(self
):
1168 items
= self
.selected_items()
1171 selected_commits
= self
.sort_by_generation([n
.commit
for n
in items
])
1172 sha1s
= [c
.sha1
for c
in selected_commits
]
1173 all_sha1s
= [c
.sha1
for c
in self
.commits
]
1174 cmds
.do(cmds
.FormatPatch
, sha1s
, all_sha1s
)
1176 def select_parent(self
):
1177 """Select the parent with the newest generation number"""
1178 selected_item
= self
.selected_item()
1179 if selected_item
is None:
1181 parent_item
= self
.newest_item(selected_item
.commit
.parents
)
1182 if parent_item
is None:
1184 selected_item
.setSelected(False)
1185 parent_item
.setSelected(True)
1187 parent_item
.mapRectToScene(parent_item
.boundingRect()))
1189 def select_oldest_parent(self
):
1190 """Select the parent with the oldest generation number"""
1191 selected_item
= self
.selected_item()
1192 if selected_item
is None:
1194 parent_item
= self
.oldest_item(selected_item
.commit
.parents
)
1195 if parent_item
is None:
1197 selected_item
.setSelected(False)
1198 parent_item
.setSelected(True)
1199 scene_rect
= parent_item
.mapRectToScene(parent_item
.boundingRect())
1200 self
.ensureVisible(scene_rect
)
1202 def select_child(self
):
1203 """Select the child with the oldest generation number"""
1204 selected_item
= self
.selected_item()
1205 if selected_item
is None:
1207 child_item
= self
.oldest_item(selected_item
.commit
.children
)
1208 if child_item
is None:
1210 selected_item
.setSelected(False)
1211 child_item
.setSelected(True)
1212 scene_rect
= child_item
.mapRectToScene(child_item
.boundingRect())
1213 self
.ensureVisible(scene_rect
)
1215 def select_newest_child(self
):
1216 """Select the Nth child with the newest generation number (N > 1)"""
1217 selected_item
= self
.selected_item()
1218 if selected_item
is None:
1220 if len(selected_item
.commit
.children
) > 1:
1221 children
= selected_item
.commit
.children
[1:]
1223 children
= selected_item
.commit
.children
1224 child_item
= self
.newest_item(children
)
1225 if child_item
is None:
1227 selected_item
.setSelected(False)
1228 child_item
.setSelected(True)
1229 scene_rect
= child_item
.mapRectToScene(child_item
.boundingRect())
1230 self
.ensureVisible(scene_rect
)
1232 def set_initial_view(self
):
1233 self_commits
= self
.commits
1234 self_items
= self
.items
1236 items
= self
.selected_items()
1238 commits
= self_commits
[-8:]
1239 items
= [self_items
[c
.sha1
] for c
in commits
]
1241 self
.fit_view_to_items(items
)
1243 def zoom_to_fit(self
):
1244 """Fit selected items into the viewport"""
1246 items
= self
.selected_items()
1247 self
.fit_view_to_items(items
)
1249 def fit_view_to_items(self
, items
):
1251 rect
= self
.scene().itemsBoundingRect()
1253 maxint
= 9223372036854775807
1260 item_rect
= item
.boundingRect()
1261 x_off
= item_rect
.width() * 5
1262 y_off
= item_rect
.height() * 10
1263 x_min
= min(x_min
, pos
.x())
1264 y_min
= min(y_min
, pos
.y()-y_off
)
1265 x_max
= max(x_max
, pos
.x()+x_off
)
1266 ymax
= max(ymax
, pos
.y())
1267 rect
= QtCore
.QRectF(x_min
, y_min
, x_max
-x_min
, ymax
-y_min
)
1268 x_adjust
= GraphView
.x_adjust
1269 y_adjust
= GraphView
.y_adjust
1270 rect
.setX(rect
.x() - x_adjust
)
1271 rect
.setY(rect
.y() - y_adjust
)
1272 rect
.setHeight(rect
.height() + y_adjust
*2)
1273 rect
.setWidth(rect
.width() + x_adjust
*2)
1274 self
.fitInView(rect
, Qt
.KeepAspectRatio
)
1275 self
.scene().invalidate()
1277 def save_selection(self
, event
):
1278 if event
.button() != Qt
.LeftButton
:
1280 elif Qt
.ShiftModifier
!= event
.modifiers():
1282 self
.selection_list
= self
.selected_items()
1284 def restore_selection(self
, event
):
1285 if Qt
.ShiftModifier
!= event
.modifiers():
1287 for item
in self
.selection_list
:
1288 item
.setSelected(True)
1290 def handle_event(self
, event_handler
, event
):
1291 self
.save_selection(event
)
1292 event_handler(self
, event
)
1293 self
.restore_selection(event
)
1296 def set_selecting(self
, selecting
):
1297 self
.selecting
= selecting
1299 def pan(self
, event
):
1301 dx
= pos
.x() - self
.mouse_start
[0]
1302 dy
= pos
.y() - self
.mouse_start
[1]
1304 if dx
== 0 and dy
== 0:
1307 rect
= QtCore
.QRect(0, 0, abs(dx
), abs(dy
))
1308 delta
= self
.mapToScene(rect
).boundingRect()
1318 matrix
= QtGui
.QMatrix(self
.saved_matrix
).translate(tx
, ty
)
1319 self
.setTransformationAnchor(QtGui
.QGraphicsView
.NoAnchor
)
1320 self
.setMatrix(matrix
)
1322 def wheel_zoom(self
, event
):
1323 """Handle mouse wheel zooming."""
1324 zoom
= math
.pow(2.0, event
.delta()/512.0)
1325 factor
= (self
.matrix()
1327 .mapRect(QtCore
.QRectF(0.0, 0.0, 1.0, 1.0))
1329 if factor
< 0.014 or factor
> 42.0:
1331 self
.setTransformationAnchor(QtGui
.QGraphicsView
.AnchorUnderMouse
)
1333 self
.scale(zoom
, zoom
)
1335 def wheel_pan(self
, event
):
1336 """Handle mouse wheel panning."""
1338 if event
.delta() < 0:
1342 pan_rect
= QtCore
.QRectF(0.0, 0.0, 1.0, 1.0)
1343 factor
= 1.0/self
.matrix().mapRect(pan_rect
).width()
1345 if event
.orientation() == Qt
.Vertical
:
1346 matrix
= self
.matrix().translate(0, s
*factor
)
1348 matrix
= self
.matrix().translate(s
*factor
, 0)
1349 self
.setTransformationAnchor(QtGui
.QGraphicsView
.NoAnchor
)
1350 self
.setMatrix(matrix
)
1352 def scale_view(self
, scale
):
1353 factor
= (self
.matrix().scale(scale
, scale
)
1354 .mapRect(QtCore
.QRectF(0, 0, 1, 1))
1356 if factor
< 0.07 or factor
> 100.0:
1360 adjust_scrollbars
= True
1361 scrollbar
= self
.verticalScrollBar()
1363 value
= scrollbar
.value()
1364 min_
= scrollbar
.minimum()
1365 max_
= scrollbar
.maximum()
1366 range_
= max_
- min_
1367 distance
= value
- min_
1368 nonzero_range
= range_
> 0.1
1370 scrolloffset
= distance
/range_
1372 adjust_scrollbars
= False
1374 self
.setTransformationAnchor(QtGui
.QGraphicsView
.NoAnchor
)
1375 self
.scale(scale
, scale
)
1377 scrollbar
= self
.verticalScrollBar()
1378 if scrollbar
and adjust_scrollbars
:
1379 min_
= scrollbar
.minimum()
1380 max_
= scrollbar
.maximum()
1381 range_
= max_
- min_
1382 value
= min_
+ int(float(range_
) * scrolloffset
)
1383 scrollbar
.setValue(value
)
1385 def add_commits(self
, commits
):
1386 """Traverse commits and add them to the view."""
1387 self
.commits
.extend(commits
)
1388 scene
= self
.scene()
1389 for commit
in commits
:
1390 item
= Commit(commit
, self
.notifier
)
1391 self
.items
[commit
.sha1
] = item
1392 for ref
in commit
.tags
:
1393 self
.items
[ref
] = item
1396 self
.layout_commits(commits
)
1399 def link(self
, commits
):
1400 """Create edges linking commits with their parents"""
1401 scene
= self
.scene()
1402 for commit
in commits
:
1404 commit_item
= self
.items
[commit
.sha1
]
1406 # TODO - Handle truncated history viewing
1408 for parent
in reversed(commit
.parents
):
1410 parent_item
= self
.items
[parent
.sha1
]
1412 # TODO - Handle truncated history viewing
1414 edge
= Edge(parent_item
, commit_item
)
1417 def layout_commits(self
, nodes
):
1418 positions
= self
.position_nodes(nodes
)
1419 for sha1
, (x
, y
) in positions
.items():
1420 item
= self
.items
[sha1
]
1423 def position_nodes(self
, nodes
):
1430 x_offsets
= self
.x_offsets
1433 generation
= node
.generation
1437 # This is a fan-out so sweep over child generations and
1438 # shift them to the right to avoid overlapping edges
1439 child_gens
= [c
.generation
for c
in node
.children
]
1440 maxgen
= max(child_gens
)
1441 for g
in range(generation
+ 1, maxgen
):
1442 x_offsets
[g
] += x_off
1444 if len(node
.parents
) == 1:
1445 # Align nodes relative to their parents
1446 parent_gen
= node
.parents
[0].generation
1447 parent_off
= x_offsets
[parent_gen
]
1448 x_offsets
[generation
] = max(parent_off
-x_off
,
1449 x_offsets
[generation
])
1451 cur_xoff
= x_offsets
[generation
]
1452 next_xoff
= cur_xoff
1454 x_offsets
[generation
] = next_xoff
1457 y_pos
= -generation
* y_off
1459 y_pos
= min(y_pos
, y_min
- y_off
)
1462 positions
[sha1
] = (x_pos
, y_pos
)
1464 x_max
= max(x_max
, x_pos
)
1472 def update_scene_rect(self
):
1475 self
.scene().setSceneRect(-GraphView
.x_adjust
,
1476 y_min
-GraphView
.y_adjust
,
1477 x_max
+ GraphView
.x_adjust
,
1478 abs(y_min
) + GraphView
.y_adjust
)
1480 def sort_by_generation(self
, commits
):
1481 if len(commits
) < 2:
1483 commits
.sort(key
=lambda x
: x
.generation
)
1487 def contextMenuEvent(self
, event
):
1488 self
.context_menu_event(event
)
1490 def mousePressEvent(self
, event
):
1491 if event
.button() == Qt
.MidButton
:
1493 self
.mouse_start
= [pos
.x(), pos
.y()]
1494 self
.saved_matrix
= QtGui
.QMatrix(self
.matrix())
1495 self
.is_panning
= True
1497 if event
.button() == Qt
.RightButton
:
1500 if event
.button() == Qt
.LeftButton
:
1502 self
.handle_event(QtGui
.QGraphicsView
.mousePressEvent
, event
)
1504 def mouseMoveEvent(self
, event
):
1505 pos
= self
.mapToScene(event
.pos())
1509 self
.last_mouse
[0] = pos
.x()
1510 self
.last_mouse
[1] = pos
.y()
1511 self
.handle_event(QtGui
.QGraphicsView
.mouseMoveEvent
, event
)
1513 self
.viewport().repaint()
1515 def mouseReleaseEvent(self
, event
):
1516 self
.pressed
= False
1517 if event
.button() == Qt
.MidButton
:
1518 self
.is_panning
= False
1520 self
.handle_event(QtGui
.QGraphicsView
.mouseReleaseEvent
, event
)
1521 self
.selection_list
= []
1522 self
.viewport().repaint()
1524 def wheelEvent(self
, event
):
1525 """Handle Qt mouse wheel events."""
1526 if event
.modifiers() & Qt
.ControlModifier
:
1527 self
.wheel_zoom(event
)
1529 self
.wheel_pan(event
)