commitmsg: hide the menu indicator
[git-cola.git] / cola / widgets / dag.py
blob3a92364cdf5fa5058d05cd86cb442d990c1cd330
1 import collections
2 import math
3 import sys
5 from PyQt4 import QtGui
6 from PyQt4 import QtCore
7 from PyQt4.QtCore import Qt
8 from PyQt4.QtCore import SIGNAL
9 from PyQt4.QtCore import QPointF
10 from PyQt4.QtCore import QRectF
12 from cola import cmds
13 from cola import difftool
14 from cola import observable
15 from cola import qtutils
16 from cola.i18n import N_
17 from cola.models.dag import DAG
18 from cola.models.dag import RepoReader
19 from cola.widgets import completion
20 from cola.widgets import defs
21 from cola.widgets.createbranch import create_new_branch
22 from cola.widgets.createtag import create_tag
23 from cola.widgets.archive import GitArchiveDialog
24 from cola.widgets.browse import BrowseDialog
25 from cola.widgets.standard import MainWindow
26 from cola.widgets.standard import TreeWidget
27 from cola.widgets.diff import COMMITS_SELECTED
28 from cola.widgets.diff import DiffWidget
31 def git_dag(model, args=None):
32 """Return a pre-populated git DAG widget."""
33 branch = model.currentbranch
34 # disambiguate between branch names and filenames by using '--'
35 branch_doubledash = branch and (branch + ' --') or ''
36 dag = DAG(branch_doubledash, 1000)
37 dag.set_arguments(args)
39 view = DAGView(model, dag, None)
40 if dag.ref:
41 view.display()
42 return view
45 class ViewerMixin(object):
46 """Implementations must provide selected_items()"""
48 def __init__(self):
49 self.selected = None
50 self.clicked = None
51 self.menu_actions = self.context_menu_actions()
53 def selected_item(self):
54 """Return the currently selected item"""
55 selected_items = self.selected_items()
56 if not selected_items:
57 return None
58 return selected_items[0]
60 def selected_sha1(self):
61 item = self.selected_item()
62 if item is None:
63 return None
64 return item.commit.sha1
66 def diff_selected_this(self):
67 clicked_sha1 = self.clicked.sha1
68 selected_sha1 = self.selected.sha1
69 self.emit(SIGNAL('diff_commits'), selected_sha1, clicked_sha1)
71 def diff_this_selected(self):
72 clicked_sha1 = self.clicked.sha1
73 selected_sha1 = self.selected.sha1
74 self.emit(SIGNAL('diff_commits'), clicked_sha1, selected_sha1)
76 def cherry_pick(self):
77 sha1 = self.selected_sha1()
78 if sha1 is None:
79 return
80 cmds.do(cmds.CherryPick, [sha1])
82 def copy_to_clipboard(self):
83 sha1 = self.selected_sha1()
84 if sha1 is None:
85 return
86 qtutils.set_clipboard(sha1)
88 def create_branch(self):
89 sha1 = self.selected_sha1()
90 if sha1 is None:
91 return
92 create_new_branch(revision=sha1)
94 def create_tag(self):
95 sha1 = self.selected_sha1()
96 if sha1 is None:
97 return
98 create_tag(ref=sha1)
100 def create_tarball(self):
101 sha1 = self.selected_sha1()
102 if sha1 is None:
103 return
104 short_sha1 = sha1[:7]
105 GitArchiveDialog.save(sha1, short_sha1, self)
107 def save_blob_dialog(self):
108 sha1 = self.selected_sha1()
109 if sha1 is None:
110 return
111 return BrowseDialog.browse(sha1)
113 def context_menu_actions(self):
114 return {
115 'diff_this_selected':
116 qtutils.add_action(self, N_('Diff this -> selected'),
117 self.diff_this_selected),
118 'diff_selected_this':
119 qtutils.add_action(self, N_('Diff selected -> this'),
120 self.diff_selected_this),
121 'create_branch':
122 qtutils.add_action(self, N_('Create Branch'),
123 self.create_branch),
124 'create_patch':
125 qtutils.add_action(self, N_('Create Patch'),
126 self.create_patch),
127 'create_tag':
128 qtutils.add_action(self, N_('Create Tag'),
129 self.create_tag),
130 'create_tarball':
131 qtutils.add_action(self, N_('Save As Tarball/Zip...'),
132 self.create_tarball),
133 'cherry_pick':
134 qtutils.add_action(self, N_('Cherry Pick'),
135 self.cherry_pick),
136 'save_blob':
137 qtutils.add_action(self, N_('Grab File...'),
138 self.save_blob_dialog),
139 'copy':
140 qtutils.add_action(self, N_('Copy SHA-1'),
141 self.copy_to_clipboard,
142 QtGui.QKeySequence.Copy),
145 def update_menu_actions(self, event):
146 selected_items = self.selected_items()
147 item = self.itemAt(event.pos())
148 if item is None:
149 self.clicked = commit = None
150 else:
151 self.clicked = commit = item.commit
153 has_single_selection = len(selected_items) == 1
154 has_selection = bool(selected_items)
155 can_diff = bool(commit and has_single_selection and
156 commit is not selected_items[0].commit)
158 if can_diff:
159 self.selected = selected_items[0].commit
160 else:
161 self.selected = None
163 self.menu_actions['diff_this_selected'].setEnabled(can_diff)
164 self.menu_actions['diff_selected_this'].setEnabled(can_diff)
166 self.menu_actions['create_branch'].setEnabled(has_single_selection)
167 self.menu_actions['create_tag'].setEnabled(has_single_selection)
169 self.menu_actions['cherry_pick'].setEnabled(has_single_selection)
170 self.menu_actions['create_patch'].setEnabled(has_selection)
171 self.menu_actions['create_tarball'].setEnabled(has_single_selection)
173 self.menu_actions['save_blob'].setEnabled(has_single_selection)
174 self.menu_actions['copy'].setEnabled(has_single_selection)
176 def context_menu_event(self, event):
177 self.update_menu_actions(event)
178 menu = QtGui.QMenu(self)
179 menu.addAction(self.menu_actions['diff_this_selected'])
180 menu.addAction(self.menu_actions['diff_selected_this'])
181 menu.addSeparator()
182 menu.addAction(self.menu_actions['create_branch'])
183 menu.addAction(self.menu_actions['create_tag'])
184 menu.addSeparator()
185 menu.addAction(self.menu_actions['cherry_pick'])
186 menu.addAction(self.menu_actions['create_patch'])
187 menu.addAction(self.menu_actions['create_tarball'])
188 menu.addSeparator()
189 menu.addAction(self.menu_actions['save_blob'])
190 menu.addAction(self.menu_actions['copy'])
191 menu.exec_(self.mapToGlobal(event.pos()))
194 class CommitTreeWidgetItem(QtGui.QTreeWidgetItem):
196 def __init__(self, commit, parent=None):
197 QtGui.QTreeWidgetItem.__init__(self, parent)
198 self.commit = commit
199 self.setText(0, commit.summary)
200 self.setText(1, commit.author)
201 self.setText(2, commit.authdate)
204 class CommitTreeWidget(ViewerMixin, TreeWidget):
206 def __init__(self, notifier, parent):
207 TreeWidget.__init__(self, parent)
208 ViewerMixin.__init__(self)
210 self.setSelectionMode(self.ContiguousSelection)
211 self.setHeaderLabels([N_('Summary'), N_('Author'), N_('Date, Time')])
213 self.sha1map = {}
214 self.notifier = notifier
215 self.selecting = False
216 self.commits = []
218 self.action_up = qtutils.add_action(self, N_('Go Up'), self.go_up,
219 Qt.Key_K)
221 self.action_down = qtutils.add_action(self, N_('Go Down'), self.go_down,
222 Qt.Key_J)
224 notifier.add_observer(COMMITS_SELECTED, self.commits_selected)
226 self.connect(self, SIGNAL('itemSelectionChanged()'),
227 self.selection_changed)
229 # ViewerMixin
230 def go_up(self):
231 self.goto(self.itemAbove)
233 def go_down(self):
234 self.goto(self.itemBelow)
236 def goto(self, finder):
237 items = self.selected_items()
238 item = items and items[0] or None
239 if item is None:
240 return
241 found = finder(item)
242 if found:
243 self.select([found.commit.sha1], block_signals=False)
245 def set_selecting(self, selecting):
246 self.selecting = selecting
248 def selection_changed(self):
249 items = self.selected_items()
250 if not items:
251 return
252 self.set_selecting(True)
253 self.notifier.notify_observers(COMMITS_SELECTED,
254 [i.commit for i in items])
255 self.set_selecting(False)
257 def commits_selected(self, commits):
258 if self.selecting:
259 return
260 self.select([commit.sha1 for commit in commits])
262 def select(self, sha1s, block_signals=True):
263 self.clearSelection()
264 for sha1 in sha1s:
265 try:
266 item = self.sha1map[sha1]
267 except KeyError:
268 continue
269 block = self.blockSignals(block_signals)
270 self.scrollToItem(item)
271 item.setSelected(True)
272 self.blockSignals(block)
274 def adjust_columns(self):
275 width = self.width()-20
276 zero = width*2/3
277 onetwo = width/6
278 self.setColumnWidth(0, zero)
279 self.setColumnWidth(1, onetwo)
280 self.setColumnWidth(2, onetwo)
282 def clear(self):
283 QtGui.QTreeWidget.clear(self)
284 self.sha1map.clear()
285 self.commits = []
287 def add_commits(self, commits):
288 self.commits.extend(commits)
289 items = []
290 for c in reversed(commits):
291 item = CommitTreeWidgetItem(c)
292 items.append(item)
293 self.sha1map[c.sha1] = item
294 for tag in c.tags:
295 self.sha1map[tag] = item
296 self.insertTopLevelItems(0, items)
298 def create_patch(self):
299 items = self.selectedItems()
300 if not items:
301 return
302 items.reverse()
303 sha1s = [item.commit.sha1 for item in items]
304 all_sha1s = [c.sha1 for c in self.commits]
305 cmds.do(cmds.FormatPatch, sha1s, all_sha1s)
307 # Qt overrides
308 def contextMenuEvent(self, event):
309 self.context_menu_event(event)
311 def mousePressEvent(self, event):
312 if event.button() == Qt.RightButton:
313 event.accept()
314 return
315 QtGui.QTreeWidget.mousePressEvent(self, event)
318 class DAGView(MainWindow):
319 """The git-dag widget."""
321 def __init__(self, model, dag, parent=None, args=None):
322 MainWindow.__init__(self, parent)
324 self.setAttribute(Qt.WA_MacMetalStyle)
325 self.setMinimumSize(420, 420)
327 # change when widgets are added/removed
328 self.widget_version = 1
329 self.model = model
330 self.dag = dag
332 self.commits = {}
333 self.commit_list = []
335 self.old_count = None
336 self.old_ref = None
337 self.thread = ReaderThread(dag, self)
339 self.revtext = completion.GitLogLineEdit()
341 self.maxresults = QtGui.QSpinBox()
342 self.maxresults.setMinimum(1)
343 self.maxresults.setMaximum(99999)
344 self.maxresults.setPrefix('')
345 self.maxresults.setSuffix('')
347 self.zoom_out = qtutils.create_action_button(
348 tooltip=N_('Zoom Out'),
349 icon=qtutils.theme_icon('zoom-out.png'))
351 self.zoom_in = qtutils.create_action_button(
352 tooltip=N_('Zoom In'),
353 icon=qtutils.theme_icon('zoom-in.png'))
355 self.zoom_to_fit = qtutils.create_action_button(
356 tooltip=N_('Zoom to Fit'),
357 icon=qtutils.theme_icon('zoom-fit-best.png'))
359 self.notifier = notifier = observable.Observable()
360 self.notifier.refs_updated = refs_updated = 'refs_updated'
361 self.notifier.add_observer(refs_updated, self.display)
363 self.treewidget = CommitTreeWidget(notifier, self)
364 self.diffwidget = DiffWidget(notifier, self)
365 self.graphview = GraphView(notifier, self)
367 self.controls_layout = QtGui.QHBoxLayout()
368 self.controls_layout.setMargin(defs.no_margin)
369 self.controls_layout.setSpacing(defs.spacing)
370 self.controls_layout.addWidget(self.revtext)
371 self.controls_layout.addWidget(self.maxresults)
373 self.controls_widget = QtGui.QWidget()
374 self.controls_widget.setLayout(self.controls_layout)
376 self.log_dock = qtutils.create_dock(N_('Log'), self, stretch=False)
377 self.log_dock.setWidget(self.treewidget)
378 log_dock_titlebar = self.log_dock.titleBarWidget()
379 log_dock_titlebar.add_corner_widget(self.controls_widget)
381 self.diff_dock = qtutils.create_dock(N_('Diff'), self)
382 self.diff_dock.setWidget(self.diffwidget)
384 self.graph_controls_layout = QtGui.QHBoxLayout()
385 self.graph_controls_layout.setMargin(defs.no_margin)
386 self.graph_controls_layout.setSpacing(defs.button_spacing)
387 self.graph_controls_layout.addWidget(self.zoom_out)
388 self.graph_controls_layout.addWidget(self.zoom_in)
389 self.graph_controls_layout.addWidget(self.zoom_to_fit)
391 self.graph_controls_widget = QtGui.QWidget()
392 self.graph_controls_widget.setLayout(self.graph_controls_layout)
394 self.graphview_dock = qtutils.create_dock(N_('Graph'), self)
395 self.graphview_dock.setWidget(self.graphview)
396 graph_titlebar = self.graphview_dock.titleBarWidget()
397 graph_titlebar.add_corner_widget(self.graph_controls_widget)
399 self.lock_layout_action = qtutils.add_action_bool(self,
400 N_('Lock Layout'), self.set_lock_layout, False)
402 # Create the application menu
403 self.menubar = QtGui.QMenuBar(self)
405 # View Menu
406 self.view_menu = qtutils.create_menu(N_('View'), self.menubar)
407 self.view_menu.addAction(self.log_dock.toggleViewAction())
408 self.view_menu.addAction(self.graphview_dock.toggleViewAction())
409 self.view_menu.addAction(self.diff_dock.toggleViewAction())
410 self.view_menu.addSeparator()
411 self.view_menu.addAction(self.lock_layout_action)
413 self.menubar.addAction(self.view_menu.menuAction())
414 self.setMenuBar(self.menubar)
416 left = Qt.LeftDockWidgetArea
417 right = Qt.RightDockWidgetArea
418 bottom = Qt.BottomDockWidgetArea
419 self.addDockWidget(left, self.log_dock)
420 self.addDockWidget(right, self.graphview_dock)
421 self.addDockWidget(bottom, self.diff_dock)
423 # Update fields affected by model
424 self.revtext.setText(dag.ref)
425 self.maxresults.setValue(dag.count)
426 self.update_window_title()
428 # Also re-loads dag.* from the saved state
429 if not qtutils.apply_state(self):
430 self.resize_to_desktop()
432 qtutils.connect_button(self.zoom_out, self.graphview.zoom_out)
433 qtutils.connect_button(self.zoom_in, self.graphview.zoom_in)
434 qtutils.connect_button(self.zoom_to_fit,
435 self.graphview.zoom_to_fit)
437 self.thread.connect(self.thread, self.thread.commits_ready,
438 self.add_commits)
440 self.thread.connect(self.thread, self.thread.done,
441 self.thread_done)
443 self.connect(self.treewidget, SIGNAL('diff_commits'),
444 self.diff_commits)
446 self.connect(self.graphview, SIGNAL('diff_commits'),
447 self.diff_commits)
449 self.connect(self.maxresults, SIGNAL('editingFinished()'),
450 self.display)
452 self.connect(self.revtext, SIGNAL('changed()'),
453 self.display)
455 self.connect(self.revtext, SIGNAL('textChanged(QString)'),
456 self.text_changed)
458 self.connect(self.revtext, SIGNAL('returnPressed()'),
459 self.display)
461 # The model is updated in another thread so use
462 # signals/slots to bring control back to the main GUI thread
463 self.model.add_observer(self.model.message_updated,
464 self.emit_model_updated)
466 self.connect(self, SIGNAL('model_updated'),
467 self.model_updated)
469 qtutils.add_action(self, 'Focus search field',
470 lambda: self.revtext.setFocus(), 'Ctrl+l')
472 qtutils.add_close_action(self)
474 def text_changed(self, txt):
475 self.dag.ref = unicode(txt)
476 self.update_window_title()
478 def update_window_title(self):
479 project = self.model.project
480 if self.dag.ref:
481 self.setWindowTitle(N_('%s: %s - DAG') % (project, self.dag.ref))
482 else:
483 self.setWindowTitle(project + N_(' - DAG'))
485 def export_state(self):
486 state = self.Mixin.export_state(self)
487 state['count'] = self.dag.count
488 return state
490 def apply_state(self, state):
491 result = self.Mixin.apply_state(self, state)
492 try:
493 count = state['count']
494 if self.dag.overridden('count'):
495 count = self.dag.count
496 except:
497 count = self.dag.count
498 result = False
499 self.dag.set_count(count)
500 self.lock_layout_action.setChecked(state.get('lock_layout', False))
501 return result
503 def emit_model_updated(self):
504 self.emit(SIGNAL('model_updated'))
506 def model_updated(self):
507 if self.dag.ref:
508 self.revtext.update_matches()
509 return
510 if not self.model.currentbranch:
511 return
512 self.revtext.setText(self.model.currentbranch + ' --')
513 self.display()
515 def display(self):
516 new_ref = unicode(self.revtext.text())
517 if not new_ref:
518 return
519 new_count = self.maxresults.value()
520 old_ref = self.old_ref
521 old_count = self.old_count
522 if old_ref == new_ref and old_count == new_count:
523 return
525 self.old_ref = new_ref
526 self.old_count = new_count
528 self.thread.stop()
529 self.clear()
530 self.dag.set_ref(new_ref)
531 self.dag.set_count(self.maxresults.value())
532 self.thread.start()
534 def show(self):
535 self.Mixin.show(self)
536 self.treewidget.adjust_columns()
538 def clear(self):
539 self.graphview.clear()
540 self.treewidget.clear()
541 self.commits.clear()
542 self.commit_list = []
544 def add_commits(self, commits):
545 self.commit_list.extend(commits)
546 # Keep track of commits
547 for commit_obj in commits:
548 self.commits[commit_obj.sha1] = commit_obj
549 for tag in commit_obj.tags:
550 self.commits[tag] = commit_obj
551 self.graphview.add_commits(commits)
552 self.treewidget.add_commits(commits)
554 def thread_done(self):
555 self.graphview.setFocus()
556 try:
557 commit_obj = self.commit_list[-1]
558 except IndexError:
559 return
560 self.notifier.notify_observers(COMMITS_SELECTED, [commit_obj])
561 self.graphview.update_scene_rect()
562 self.graphview.set_initial_view()
564 def resize_to_desktop(self):
565 desktop = QtGui.QApplication.instance().desktop()
566 width = desktop.width()
567 height = desktop.height()
568 self.resize(width, height)
570 def diff_commits(self, a, b):
571 paths = self.dag.paths()
572 if paths:
573 difftool.launch([a, b, '--'] + paths)
574 else:
575 difftool.diff_commits(self, a, b)
577 # Qt overrides
578 def closeEvent(self, event):
579 self.revtext.close_popup()
580 self.thread.stop()
581 self.Mixin.closeEvent(self, event)
583 def resizeEvent(self, e):
584 self.Mixin.resizeEvent(self, e)
585 self.treewidget.adjust_columns()
588 class ReaderThread(QtCore.QThread):
589 commits_ready = SIGNAL('commits_ready')
590 done = SIGNAL('done')
592 def __init__(self, dag, parent):
593 QtCore.QThread.__init__(self, parent)
594 self.dag = dag
595 self._abort = False
596 self._stop = False
597 self._mutex = QtCore.QMutex()
598 self._condition = QtCore.QWaitCondition()
600 def run(self):
601 repo = RepoReader(self.dag)
602 repo.reset()
603 commits = []
604 for c in repo:
605 self._mutex.lock()
606 if self._stop:
607 self._condition.wait(self._mutex)
608 self._mutex.unlock()
609 if self._abort:
610 repo.reset()
611 return
612 commits.append(c)
613 if len(commits) >= 512:
614 self.emit(self.commits_ready, commits)
615 commits = []
617 if commits:
618 self.emit(self.commits_ready, commits)
619 self.emit(self.done)
621 def start(self):
622 self._abort = False
623 self._stop = False
624 QtCore.QThread.start(self)
626 def pause(self):
627 self._mutex.lock()
628 self._stop = True
629 self._mutex.unlock()
631 def resume(self):
632 self._mutex.lock()
633 self._stop = False
634 self._mutex.unlock()
635 self._condition.wakeOne()
637 def stop(self):
638 self._abort = True
639 self.wait()
642 class Cache(object):
643 pass
646 class Edge(QtGui.QGraphicsItem):
647 item_type = QtGui.QGraphicsItem.UserType + 1
649 def __init__(self, source, dest):
651 QtGui.QGraphicsItem.__init__(self)
653 self.setAcceptedMouseButtons(Qt.NoButton)
654 self.source = source
655 self.dest = dest
656 self.commit = source.commit
657 self.setZValue(-2)
659 dest_pt = Commit.item_bbox.center()
661 self.source_pt = self.mapFromItem(self.source, dest_pt)
662 self.dest_pt = self.mapFromItem(self.dest, dest_pt)
663 self.line = QtCore.QLineF(self.source_pt, self.dest_pt)
665 width = self.dest_pt.x() - self.source_pt.x()
666 height = self.dest_pt.y() - self.source_pt.y()
667 rect = QtCore.QRectF(self.source_pt, QtCore.QSizeF(width, height))
668 self.bound = rect.normalized()
670 # Choose a new color for new branch edges
671 if self.source.x() < self.dest.x():
672 color = EdgeColor.next()
673 line = Qt.SolidLine
674 elif self.source.x() != self.dest.x():
675 color = EdgeColor.current()
676 line = Qt.SolidLine
677 else:
678 color = EdgeColor.current()
679 line = Qt.SolidLine
681 self.pen = QtGui.QPen(color, 4.0, line, Qt.SquareCap, Qt.RoundJoin)
683 # Qt overrides
684 def type(self):
685 return self.item_type
687 def boundingRect(self):
688 return self.bound
690 def paint(self, painter, option, widget):
692 arc_rect = 10
693 connector_length = 5
695 painter.setPen(self.pen)
696 path = QtGui.QPainterPath()
698 if self.source.x() == self.dest.x():
699 path.moveTo(self.source.x(), self.source.y())
700 path.lineTo(self.dest.x(), self.dest.y())
701 painter.drawPath(path)
703 else:
705 #Define points starting from source
706 point1 = QPointF(self.source.x(), self.source.y())
707 point2 = QPointF(point1.x(), point1.y() - connector_length)
708 point3 = QPointF(point2.x() + arc_rect, point2.y() - arc_rect)
710 #Define points starting from dest
711 point4 = QPointF(self.dest.x(), self.dest.y())
712 point5 = QPointF(point4.x(),point3.y() - arc_rect)
713 point6 = QPointF(point5.x() - arc_rect, point5.y() + arc_rect)
715 start_angle_arc1 = 180
716 span_angle_arc1 = 90
717 start_angle_arc2 = 90
718 span_angle_arc2 = -90
720 # If the dest is at the left of the source, then we
721 # need to reverse some values
722 if self.source.x() > self.dest.x():
723 point5 = QPointF(point4.x(), point4.y() + connector_length)
724 point6 = QPointF(point5.x() + arc_rect, point5.y() + arc_rect)
725 point3 = QPointF(self.source.x() - arc_rect, point6.y())
726 point2 = QPointF(self.source.x(), point3.y() + arc_rect)
728 span_angle_arc1 = 90
730 path.moveTo(point1)
731 path.lineTo(point2)
732 path.arcTo(QRectF(point2, point3),
733 start_angle_arc1, span_angle_arc1)
734 path.lineTo(point6)
735 path.arcTo(QRectF(point6, point5),
736 start_angle_arc2, span_angle_arc2)
737 path.lineTo(point4)
738 painter.drawPath(path)
741 class EdgeColor(object):
742 """An edge color factory"""
744 current_color_index = 0
745 colors = [
746 QtGui.QColor(Qt.red),
747 QtGui.QColor(Qt.green),
748 QtGui.QColor(Qt.blue),
749 QtGui.QColor(Qt.black),
750 QtGui.QColor(Qt.darkRed),
751 QtGui.QColor(Qt.darkGreen),
752 QtGui.QColor(Qt.darkBlue),
753 QtGui.QColor(Qt.cyan),
754 QtGui.QColor(Qt.magenta),
755 # Orange; Qt.yellow is too low-contrast
756 qtutils.rgba(0xff, 0x66, 0x00),
757 QtGui.QColor(Qt.gray),
758 QtGui.QColor(Qt.darkCyan),
759 QtGui.QColor(Qt.darkMagenta),
760 QtGui.QColor(Qt.darkYellow),
761 QtGui.QColor(Qt.darkGray),
764 @classmethod
765 def next(cls):
766 cls.current_color_index += 1
767 cls.current_color_index %= len(cls.colors)
768 color = cls.colors[cls.current_color_index]
769 color.setAlpha(128)
770 return color
772 @classmethod
773 def current(cls):
774 return cls.colors[cls.current_color_index]
777 class Commit(QtGui.QGraphicsItem):
778 item_type = QtGui.QGraphicsItem.UserType + 2
779 commit_radius = 12.0
780 merge_radius = 18.0
782 item_shape = QtGui.QPainterPath()
783 item_shape.addRect(commit_radius/-2.0,
784 commit_radius/-2.0,
785 commit_radius, commit_radius)
786 item_bbox = item_shape.boundingRect()
788 inner_rect = QtGui.QPainterPath()
789 inner_rect.addRect(commit_radius/-2.0 + 2.0,
790 commit_radius/-2.0 + 2.0,
791 commit_radius - 4.0,
792 commit_radius - 4.0)
793 inner_rect = inner_rect.boundingRect()
795 commit_color = QtGui.QColor(Qt.white)
796 outline_color = commit_color.darker()
797 merge_color = QtGui.QColor(Qt.lightGray)
799 commit_selected_color = QtGui.QColor(Qt.green)
800 selected_outline_color = commit_selected_color.darker()
802 commit_pen = QtGui.QPen()
803 commit_pen.setWidth(1.0)
804 commit_pen.setColor(outline_color)
806 def __init__(self, commit,
807 notifier,
808 selectable=QtGui.QGraphicsItem.ItemIsSelectable,
809 cursor=Qt.PointingHandCursor,
810 xpos=commit_radius/2.0 + 1.0,
811 cached_commit_color=commit_color,
812 cached_merge_color=merge_color):
814 QtGui.QGraphicsItem.__init__(self)
816 self.commit = commit
817 self.notifier = notifier
819 self.setZValue(0)
820 self.setFlag(selectable)
821 self.setCursor(cursor)
822 self.setToolTip(commit.sha1[:7] + ': ' + commit.summary)
824 if commit.tags:
825 self.label = label = Label(commit)
826 label.setParentItem(self)
827 label.setPos(xpos, -self.commit_radius/2.0)
828 else:
829 self.label = None
831 if len(commit.parents) > 1:
832 self.brush = cached_merge_color
833 else:
834 self.brush = cached_commit_color
836 self.pressed = False
837 self.dragged = False
839 def blockSignals(self, blocked):
840 self.notifier.notification_enabled = not blocked
842 def itemChange(self, change, value):
843 if change == QtGui.QGraphicsItem.ItemSelectedHasChanged:
844 # Broadcast selection to other widgets
845 selected_items = self.scene().selectedItems()
846 commits = [item.commit for item in selected_items]
847 self.scene().parent().set_selecting(True)
848 self.notifier.notify_observers(COMMITS_SELECTED, commits)
849 self.scene().parent().set_selecting(False)
851 # Cache the pen for use in paint()
852 if value.toPyObject():
853 self.brush = self.commit_selected_color
854 color = self.selected_outline_color
855 else:
856 if len(self.commit.parents) > 1:
857 self.brush = self.merge_color
858 else:
859 self.brush = self.commit_color
860 color = self.outline_color
861 commit_pen = QtGui.QPen()
862 commit_pen.setWidth(1.0)
863 commit_pen.setColor(color)
864 self.commit_pen = commit_pen
866 return QtGui.QGraphicsItem.itemChange(self, change, value)
868 def type(self):
869 return self.item_type
871 def boundingRect(self, rect=item_bbox):
872 return rect
874 def shape(self):
875 return self.item_shape
877 def paint(self, painter, option, widget,
878 inner=inner_rect,
879 cache=Cache):
881 # Do not draw outside the exposed rect
882 painter.setClipRect(option.exposedRect)
884 # Draw ellipse
885 painter.setPen(self.commit_pen)
886 painter.setBrush(self.brush)
887 painter.drawEllipse(inner)
890 def mousePressEvent(self, event):
891 QtGui.QGraphicsItem.mousePressEvent(self, event)
892 self.pressed = True
893 self.selected = self.isSelected()
895 def mouseMoveEvent(self, event):
896 if self.pressed:
897 self.dragged = True
898 QtGui.QGraphicsItem.mouseMoveEvent(self, event)
900 def mouseReleaseEvent(self, event):
901 QtGui.QGraphicsItem.mouseReleaseEvent(self, event)
902 if (not self.dragged and
903 self.selected and
904 event.button() == Qt.LeftButton):
905 return
906 self.pressed = False
907 self.dragged = False
910 class Label(QtGui.QGraphicsItem):
911 item_type = QtGui.QGraphicsItem.UserType + 3
913 width = 72
914 height = 18
916 item_shape = QtGui.QPainterPath()
917 item_shape.addRect(0, 0, width, height)
918 item_bbox = item_shape.boundingRect()
920 text_options = QtGui.QTextOption()
921 text_options.setAlignment(Qt.AlignCenter)
922 text_options.setAlignment(Qt.AlignVCenter)
924 def __init__(self, commit,
925 other_color=QtGui.QColor(Qt.white),
926 head_color=QtGui.QColor(Qt.green)):
927 QtGui.QGraphicsItem.__init__(self)
928 self.setZValue(-1)
930 # Starts with enough space for two tags. Any more and the commit
931 # needs to be taller to accomodate.
932 self.commit = commit
934 if 'HEAD' in commit.tags:
935 self.color = head_color
936 else:
937 self.color = other_color
939 self.color.setAlpha(180)
940 self.pen = QtGui.QPen()
941 self.pen.setColor(self.color.darker())
942 self.pen.setWidth(1.0)
944 def type(self):
945 return self.item_type
947 def boundingRect(self, rect=item_bbox):
948 return rect
950 def shape(self):
951 return self.item_shape
953 def paint(self, painter, option, widget,
954 text_opts=text_options,
955 black=Qt.black,
956 cache=Cache):
957 try:
958 font = cache.label_font
959 except AttributeError:
960 font = cache.label_font = QtGui.QApplication.font()
961 font.setPointSize(6)
964 # Draw tags
965 painter.setBrush(self.color)
966 painter.setPen(self.pen)
967 painter.setFont(font)
969 current_width = 0
971 for tag in self.commit.tags:
972 text_rect = painter.boundingRect(
973 QRectF(current_width, 0, 0, 0), Qt.TextSingleLine, tag)
974 box_rect = text_rect.adjusted(-1, -1, 1, 1)
975 painter.drawRoundedRect(box_rect, 2, 2)
976 painter.drawText(text_rect, Qt.TextSingleLine, tag)
977 current_width += text_rect.width() + 5
980 class GraphView(ViewerMixin, QtGui.QGraphicsView):
982 x_max = 0
983 y_min = 0
985 x_adjust = Commit.commit_radius*4/3
986 y_adjust = Commit.commit_radius*4/3
988 x_off = 18
989 y_off = 24
991 def __init__(self, notifier, parent):
992 QtGui.QGraphicsView.__init__(self, parent)
993 ViewerMixin.__init__(self)
995 highlight = self.palette().color(QtGui.QPalette.Highlight)
996 Commit.commit_selected_color = highlight
997 Commit.selected_outline_color = highlight.darker()
999 self.selection_list = []
1000 self.notifier = notifier
1001 self.commits = []
1002 self.items = {}
1003 self.saved_matrix = QtGui.QMatrix(self.matrix())
1005 self.x_offsets = collections.defaultdict(int)
1007 self.is_panning = False
1008 self.pressed = False
1009 self.selecting = False
1010 self.last_mouse = [0, 0]
1011 self.zoom = 2
1012 self.setDragMode(self.RubberBandDrag)
1014 scene = QtGui.QGraphicsScene(self)
1015 scene.setItemIndexMethod(QtGui.QGraphicsScene.NoIndex)
1016 self.setScene(scene)
1018 self.setRenderHint(QtGui.QPainter.Antialiasing)
1019 self.setViewportUpdateMode(self.BoundingRectViewportUpdate)
1020 self.setCacheMode(QtGui.QGraphicsView.CacheBackground)
1021 self.setTransformationAnchor(QtGui.QGraphicsView.AnchorUnderMouse)
1022 self.setResizeAnchor(QtGui.QGraphicsView.NoAnchor)
1023 self.setBackgroundBrush(QtGui.QColor(Qt.white))
1025 qtutils.add_action(self, N_('Zoom In'),
1026 self.zoom_in, Qt.Key_Plus, Qt.Key_Equal)
1028 qtutils.add_action(self, N_('Zoom Out'),
1029 self.zoom_out, Qt.Key_Minus)
1031 qtutils.add_action(self, N_('Zoom to Fit'),
1032 self.zoom_to_fit, Qt.Key_F)
1034 qtutils.add_action(self, N_('Select Parent'),
1035 self.select_parent, 'Shift+J')
1037 qtutils.add_action(self, N_('Select Oldest Parent'),
1038 self.select_oldest_parent, Qt.Key_J)
1040 qtutils.add_action(self, N_('Select Child'),
1041 self.select_child, 'Shift+K')
1043 qtutils.add_action(self, N_('Select Newest Child'),
1044 self.select_newest_child, Qt.Key_K)
1046 notifier.add_observer(COMMITS_SELECTED, self.commits_selected)
1048 def clear(self):
1049 self.scene().clear()
1050 self.selection_list = []
1051 self.items.clear()
1052 self.x_offsets.clear()
1053 self.x_max = 0
1054 self.y_min = 0
1055 self.commits = []
1057 # ViewerMixin interface
1058 def selected_items(self):
1059 """Return the currently selected items"""
1060 return self.scene().selectedItems()
1062 def zoom_in(self):
1063 self.scale_view(1.5)
1065 def zoom_out(self):
1066 self.scale_view(1.0/1.5)
1068 def commits_selected(self, commits):
1069 if self.selecting:
1070 return
1071 self.select([commit.sha1 for commit in commits])
1073 def select(self, sha1s):
1074 """Select the item for the SHA-1"""
1075 self.scene().clearSelection()
1076 for sha1 in sha1s:
1077 try:
1078 item = self.items[sha1]
1079 except KeyError:
1080 continue
1081 item.blockSignals(True)
1082 item.setSelected(True)
1083 item.blockSignals(False)
1084 item_rect = item.sceneTransform().mapRect(item.boundingRect())
1085 self.ensureVisible(item_rect)
1087 def get_item_by_generation(self, commits, criteria_fn):
1088 """Return the item for the commit matching criteria"""
1089 if not commits:
1090 return None
1091 generation = None
1092 for commit in commits:
1093 if (generation is None or
1094 criteria_fn(generation, commit.generation)):
1095 sha1 = commit.sha1
1096 generation = commit.generation
1097 try:
1098 return self.items[sha1]
1099 except KeyError:
1100 return None
1102 def oldest_item(self, commits):
1103 """Return the item for the commit with the oldest generation number"""
1104 return self.get_item_by_generation(commits, lambda a, b: a > b)
1106 def newest_item(self, commits):
1107 """Return the item for the commit with the newest generation number"""
1108 return self.get_item_by_generation(commits, lambda a, b: a < b)
1110 def create_patch(self):
1111 items = self.selected_items()
1112 if not items:
1113 return
1114 selected_commits = self.sort_by_generation([n.commit for n in items])
1115 sha1s = [c.sha1 for c in selected_commits]
1116 all_sha1s = [c.sha1 for c in self.commits]
1117 cmds.do(cmds.FormatPatch, sha1s, all_sha1s)
1119 def select_parent(self):
1120 """Select the parent with the newest generation number"""
1121 selected_item = self.selected_item()
1122 if selected_item is None:
1123 return
1124 parent_item = self.newest_item(selected_item.commit.parents)
1125 if parent_item is None:
1126 return
1127 selected_item.setSelected(False)
1128 parent_item.setSelected(True)
1129 self.ensureVisible(
1130 parent_item.mapRectToScene(parent_item.boundingRect()))
1132 def select_oldest_parent(self):
1133 """Select the parent with the oldest generation number"""
1134 selected_item = self.selected_item()
1135 if selected_item is None:
1136 return
1137 parent_item = self.oldest_item(selected_item.commit.parents)
1138 if parent_item is None:
1139 return
1140 selected_item.setSelected(False)
1141 parent_item.setSelected(True)
1142 scene_rect = parent_item.mapRectToScene(parent_item.boundingRect())
1143 self.ensureVisible(scene_rect)
1145 def select_child(self):
1146 """Select the child with the oldest generation number"""
1147 selected_item = self.selected_item()
1148 if selected_item is None:
1149 return
1150 child_item = self.oldest_item(selected_item.commit.children)
1151 if child_item is None:
1152 return
1153 selected_item.setSelected(False)
1154 child_item.setSelected(True)
1155 scene_rect = child_item.mapRectToScene(child_item.boundingRect())
1156 self.ensureVisible(scene_rect)
1158 def select_newest_child(self):
1159 """Select the Nth child with the newest generation number (N > 1)"""
1160 selected_item = self.selected_item()
1161 if selected_item is None:
1162 return
1163 if len(selected_item.commit.children) > 1:
1164 children = selected_item.commit.children[1:]
1165 else:
1166 children = selected_item.commit.children
1167 child_item = self.newest_item(children)
1168 if child_item is None:
1169 return
1170 selected_item.setSelected(False)
1171 child_item.setSelected(True)
1172 scene_rect = child_item.mapRectToScene(child_item.boundingRect())
1173 self.ensureVisible(scene_rect)
1175 def set_initial_view(self):
1176 self_commits = self.commits
1177 self_items = self.items
1179 commits = self_commits[-2:]
1180 items = [self_items[c.sha1] for c in commits]
1181 self.fit_view_to_items(items)
1183 def zoom_to_fit(self):
1184 """Fit selected items into the viewport"""
1186 items = self.selected_items()
1187 self.fit_view_to_items(items)
1189 def fit_view_to_items(self, items):
1190 if not items:
1191 rect = self.scene().itemsBoundingRect()
1192 else:
1193 x_min = sys.maxint
1194 y_min = sys.maxint
1195 x_max = -sys.maxint
1196 ymax = -sys.maxint
1197 for item in items:
1198 pos = item.pos()
1199 item_rect = item.boundingRect()
1200 x_off = item_rect.width()
1201 y_off = item_rect.height()
1202 x_min = min(x_min, pos.x())
1203 y_min = min(y_min, pos.y()-y_off)
1204 x_max = max(x_max, pos.x()+x_off)
1205 ymax = max(ymax, pos.y())
1206 rect = QtCore.QRectF(x_min, y_min, x_max-x_min, ymax-y_min)
1207 x_adjust = GraphView.x_adjust
1208 y_adjust = GraphView.y_adjust
1209 rect.setX(rect.x() - x_adjust)
1210 rect.setY(rect.y() - y_adjust)
1211 rect.setHeight(rect.height() + y_adjust*2)
1212 rect.setWidth(rect.width() + x_adjust*2)
1213 self.fitInView(rect, Qt.KeepAspectRatio)
1214 self.scene().invalidate()
1216 def save_selection(self, event):
1217 if event.button() != Qt.LeftButton:
1218 return
1219 elif Qt.ShiftModifier != event.modifiers():
1220 return
1221 self.selection_list = self.selected_items()
1223 def restore_selection(self, event):
1224 if Qt.ShiftModifier != event.modifiers():
1225 return
1226 for item in self.selection_list:
1227 item.setSelected(True)
1229 def handle_event(self, event_handler, event):
1230 self.update()
1231 self.save_selection(event)
1232 event_handler(self, event)
1233 self.restore_selection(event)
1235 def set_selecting(self, selecting):
1236 self.selecting = selecting
1238 def pan(self, event):
1239 pos = event.pos()
1240 dx = pos.x() - self.mouse_start[0]
1241 dy = pos.y() - self.mouse_start[1]
1243 if dx == 0 and dy == 0:
1244 return
1246 rect = QtCore.QRect(0, 0, abs(dx), abs(dy))
1247 delta = self.mapToScene(rect).boundingRect()
1249 tx = delta.width()
1250 if dx < 0.0:
1251 tx = -tx
1253 ty = delta.height()
1254 if dy < 0.0:
1255 ty = -ty
1257 matrix = QtGui.QMatrix(self.saved_matrix).translate(tx, ty)
1258 self.setTransformationAnchor(QtGui.QGraphicsView.NoAnchor)
1259 self.setMatrix(matrix)
1261 def wheel_zoom(self, event):
1262 """Handle mouse wheel zooming."""
1263 zoom = math.pow(2.0, event.delta()/512.0)
1264 factor = (self.matrix()
1265 .scale(zoom, zoom)
1266 .mapRect(QtCore.QRectF(0.0, 0.0, 1.0, 1.0))
1267 .width())
1268 if factor < 0.014 or factor > 42.0:
1269 return
1270 self.setTransformationAnchor(QtGui.QGraphicsView.AnchorUnderMouse)
1271 self.zoom = zoom
1272 self.scale(zoom, zoom)
1274 def wheel_pan(self, event):
1275 """Handle mouse wheel panning."""
1277 if event.delta() < 0:
1278 s = -133.0
1279 else:
1280 s = 133.0
1281 pan_rect = QtCore.QRectF(0.0, 0.0, 1.0, 1.0)
1282 factor = 1.0/self.matrix().mapRect(pan_rect).width()
1284 if event.orientation() == Qt.Vertical:
1285 matrix = self.matrix().translate(0, s*factor)
1286 else:
1287 matrix = self.matrix().translate(s*factor, 0)
1288 self.setTransformationAnchor(QtGui.QGraphicsView.NoAnchor)
1289 self.setMatrix(matrix)
1291 def scale_view(self, scale):
1292 factor = (self.matrix().scale(scale, scale)
1293 .mapRect(QtCore.QRectF(0, 0, 1, 1))
1294 .width())
1295 if factor < 0.07 or factor > 100.0:
1296 return
1297 self.zoom = scale
1299 adjust_scrollbars = True
1300 scrollbar = self.verticalScrollBar()
1301 if scrollbar:
1302 value = scrollbar.value()
1303 min_ = scrollbar.minimum()
1304 max_ = scrollbar.maximum()
1305 range_ = max_ - min_
1306 distance = value - min_
1307 nonzero_range = float(range_) > 0.1
1308 if nonzero_range:
1309 scrolloffset = distance/float(range_)
1310 else:
1311 adjust_scrollbars = False
1313 self.setTransformationAnchor(QtGui.QGraphicsView.NoAnchor)
1314 self.scale(scale, scale)
1316 scrollbar = self.verticalScrollBar()
1317 if scrollbar and adjust_scrollbars:
1318 min_ = scrollbar.minimum()
1319 max_ = scrollbar.maximum()
1320 range_ = max_ - min_
1321 value = min_ + int(float(range_) * scrolloffset)
1322 scrollbar.setValue(value)
1324 def add_commits(self, commits):
1325 """Traverse commits and add them to the view."""
1326 self.commits.extend(commits)
1327 scene = self.scene()
1328 for commit in commits:
1329 item = Commit(commit, self.notifier)
1330 self.items[commit.sha1] = item
1331 for ref in commit.tags:
1332 self.items[ref] = item
1333 scene.addItem(item)
1335 self.layout_commits(commits)
1336 self.link(commits)
1338 def link(self, commits):
1339 """Create edges linking commits with their parents"""
1340 scene = self.scene()
1341 for commit in commits:
1342 try:
1343 commit_item = self.items[commit.sha1]
1344 except KeyError:
1345 # TODO - Handle truncated history viewing
1346 continue
1347 for parent in reversed(commit.parents):
1348 try:
1349 parent_item = self.items[parent.sha1]
1350 except KeyError:
1351 # TODO - Handle truncated history viewing
1352 continue
1353 edge = Edge(parent_item, commit_item)
1354 scene.addItem(edge)
1356 def layout_commits(self, nodes):
1357 positions = self.position_nodes(nodes)
1358 for sha1, (x, y) in positions.items():
1359 item = self.items[sha1]
1360 item.setPos(x, y)
1362 def position_nodes(self, nodes):
1363 positions = {}
1365 x_max = self.x_max
1366 y_min = self.y_min
1367 x_off = self.x_off
1368 y_off = self.y_off
1369 x_offsets = self.x_offsets
1371 for node in nodes:
1372 generation = node.generation
1373 sha1 = node.sha1
1375 if node.is_fork():
1376 # This is a fan-out so sweep over child generations and
1377 # shift them to the right to avoid overlapping edges
1378 child_gens = [c.generation for c in node.children]
1379 maxgen = max(child_gens)
1380 for g in xrange(generation + 1, maxgen):
1381 x_offsets[g] += x_off
1383 if len(node.parents) == 1:
1384 # Align nodes relative to their parents
1385 parent_gen = node.parents[0].generation
1386 parent_off = x_offsets[parent_gen]
1387 x_offsets[generation] = max(parent_off-x_off,
1388 x_offsets[generation])
1390 cur_xoff = x_offsets[generation]
1391 next_xoff = cur_xoff
1392 next_xoff += x_off
1393 x_offsets[generation] = next_xoff
1395 x_pos = cur_xoff
1396 y_pos = -generation * y_off
1398 y_pos = min(y_pos, y_min - y_off)
1400 #y_pos = y_off
1401 positions[sha1] = (x_pos, y_pos)
1403 x_max = max(x_max, x_pos)
1404 y_min = y_pos
1406 self.x_max = x_max
1407 self.y_min = y_min
1409 return positions
1411 def update_scene_rect(self):
1412 y_min = self.y_min
1413 x_max = self.x_max
1414 self.scene().setSceneRect(-GraphView.x_adjust,
1415 y_min-GraphView.y_adjust,
1416 x_max + GraphView.x_adjust,
1417 abs(y_min) + GraphView.y_adjust)
1419 def sort_by_generation(self, commits):
1420 if len(commits) < 2:
1421 return commits
1422 commits.sort(cmp=lambda a, b: cmp(a.generation, b.generation))
1423 return commits
1425 # Qt overrides
1426 def contextMenuEvent(self, event):
1427 self.context_menu_event(event)
1429 def mousePressEvent(self, event):
1430 if event.button() == Qt.MidButton:
1431 pos = event.pos()
1432 self.mouse_start = [pos.x(), pos.y()]
1433 self.saved_matrix = QtGui.QMatrix(self.matrix())
1434 self.is_panning = True
1435 return
1436 if event.button() == Qt.RightButton:
1437 event.ignore()
1438 return
1439 if event.button() == Qt.LeftButton:
1440 self.pressed = True
1441 self.handle_event(QtGui.QGraphicsView.mousePressEvent, event)
1443 def mouseMoveEvent(self, event):
1444 pos = self.mapToScene(event.pos())
1445 if self.is_panning:
1446 self.pan(event)
1447 return
1448 self.last_mouse[0] = pos.x()
1449 self.last_mouse[1] = pos.y()
1450 self.handle_event(QtGui.QGraphicsView.mouseMoveEvent, event)
1452 def mouseReleaseEvent(self, event):
1453 self.pressed = False
1454 if event.button() == Qt.MidButton:
1455 self.is_panning = False
1456 return
1457 self.handle_event(QtGui.QGraphicsView.mouseReleaseEvent, event)
1458 self.selection_list = []
1460 def wheelEvent(self, event):
1461 """Handle Qt mouse wheel events."""
1462 if event.modifiers() == Qt.ControlModifier:
1463 self.wheel_zoom(event)
1464 else:
1465 self.wheel_pan(event)