widgets: improve reuse of keyboard bindings, etc
[git-cola.git] / cola / dag / view.py
blobff75840e92ddbe94592bd1756c855ba8f31cf154
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 qt
16 from cola import qtutils
17 from cola.dag.model import RepoReader
18 from cola.i18n import N_
19 from cola.qt import create_menu
20 from cola.widgets import completion
21 from cola.widgets import defs
22 from cola.widgets.createbranch import create_new_branch
23 from cola.widgets.createtag import create_tag
24 from cola.widgets.archive import GitArchiveDialog
25 from cola.widgets.browse import BrowseDialog
26 from cola.widgets.standard import MainWindow
27 from cola.widgets.standard import TreeWidget
28 from cola.widgets.diff import COMMITS_SELECTED
29 from cola.widgets.diff import DiffWidget
32 class ViewerMixin(object):
33 """Implementations must provide selected_items()"""
35 def __init__(self):
36 self.selected = None
37 self.clicked = None
38 self.menu_actions = self.context_menu_actions()
40 def selected_item(self):
41 """Return the currently selected item"""
42 selected_items = self.selected_items()
43 if not selected_items:
44 return None
45 return selected_items[0]
47 def selected_sha1(self):
48 item = self.selected_item()
49 if item is None:
50 return None
51 return item.commit.sha1
53 def diff_selected_this(self):
54 clicked_sha1 = self.clicked.sha1
55 selected_sha1 = self.selected.sha1
56 self.emit(SIGNAL('diff_commits'), selected_sha1, clicked_sha1)
58 def diff_this_selected(self):
59 clicked_sha1 = self.clicked.sha1
60 selected_sha1 = self.selected.sha1
61 self.emit(SIGNAL('diff_commits'), clicked_sha1, selected_sha1)
63 def cherry_pick(self):
64 sha1 = self.selected_sha1()
65 if sha1 is None:
66 return
67 cmds.do(cmds.CherryPick, [sha1])
69 def copy_to_clipboard(self):
70 sha1 = self.selected_sha1()
71 if sha1 is None:
72 return
73 qtutils.set_clipboard(sha1)
75 def create_branch(self):
76 sha1 = self.selected_sha1()
77 if sha1 is None:
78 return
79 create_new_branch(revision=sha1)
81 def create_tag(self):
82 sha1 = self.selected_sha1()
83 if sha1 is None:
84 return
85 create_tag(revision=sha1)
87 def create_tarball(self):
88 sha1 = self.selected_sha1()
89 if sha1 is None:
90 return
91 short_sha1 = sha1[:7]
92 GitArchiveDialog.save(sha1, short_sha1, self)
94 def save_blob_dialog(self):
95 sha1 = self.selected_sha1()
96 if sha1 is None:
97 return
98 return BrowseDialog.browse(sha1)
100 def context_menu_actions(self):
101 return {
102 'diff_this_selected':
103 qtutils.add_action(self, N_('Diff this -> selected'),
104 self.diff_this_selected),
105 'diff_selected_this':
106 qtutils.add_action(self, N_('Diff selected -> this'),
107 self.diff_selected_this),
108 'create_branch':
109 qtutils.add_action(self, N_('Create Branch'),
110 self.create_branch),
111 'create_patch':
112 qtutils.add_action(self, N_('Create Patch'),
113 self.create_patch),
114 'create_tag':
115 qtutils.add_action(self, N_('Create Tag'),
116 self.create_tag),
117 'create_tarball':
118 qtutils.add_action(self, N_('Save As Tarball/Zip...'),
119 self.create_tarball),
120 'cherry_pick':
121 qtutils.add_action(self, N_('Cherry Pick'),
122 self.cherry_pick),
123 'save_blob':
124 qtutils.add_action(self, N_('Grab File...'),
125 self.save_blob_dialog),
126 'copy':
127 qtutils.add_action(self, N_('Copy SHA-1'),
128 self.copy_to_clipboard,
129 QtGui.QKeySequence.Copy),
132 def update_menu_actions(self, event):
133 selected_items = self.selected_items()
134 item = self.itemAt(event.pos())
135 if item is None:
136 self.clicked = commit = None
137 else:
138 self.clicked = commit = item.commit
140 has_single_selection = len(selected_items) == 1
141 has_selection = bool(selected_items)
142 can_diff = bool(commit and has_single_selection and
143 commit is not selected_items[0].commit)
145 if can_diff:
146 self.selected = selected_items[0].commit
147 else:
148 self.selected = None
150 self.menu_actions['diff_this_selected'].setEnabled(can_diff)
151 self.menu_actions['diff_selected_this'].setEnabled(can_diff)
153 self.menu_actions['create_branch'].setEnabled(has_single_selection)
154 self.menu_actions['create_tag'].setEnabled(has_single_selection)
156 self.menu_actions['cherry_pick'].setEnabled(has_single_selection)
157 self.menu_actions['create_patch'].setEnabled(has_selection)
158 self.menu_actions['create_tarball'].setEnabled(has_single_selection)
160 self.menu_actions['save_blob'].setEnabled(has_single_selection)
161 self.menu_actions['copy'].setEnabled(has_single_selection)
163 def context_menu_event(self, event):
164 self.update_menu_actions(event)
165 menu = QtGui.QMenu(self)
166 menu.addAction(self.menu_actions['diff_this_selected'])
167 menu.addAction(self.menu_actions['diff_selected_this'])
168 menu.addSeparator()
169 menu.addAction(self.menu_actions['create_branch'])
170 menu.addAction(self.menu_actions['create_tag'])
171 menu.addSeparator()
172 menu.addAction(self.menu_actions['cherry_pick'])
173 menu.addAction(self.menu_actions['create_patch'])
174 menu.addAction(self.menu_actions['create_tarball'])
175 menu.addSeparator()
176 menu.addAction(self.menu_actions['save_blob'])
177 menu.addAction(self.menu_actions['copy'])
178 menu.exec_(self.mapToGlobal(event.pos()))
181 class CommitTreeWidgetItem(QtGui.QTreeWidgetItem):
183 def __init__(self, commit, parent=None):
184 QtGui.QTreeWidgetItem.__init__(self, parent)
185 self.commit = commit
186 self.setText(0, commit.summary)
187 self.setText(1, commit.author)
188 self.setText(2, commit.authdate)
191 class CommitTreeWidget(ViewerMixin, TreeWidget):
193 def __init__(self, notifier, parent):
194 TreeWidget.__init__(self, parent)
195 ViewerMixin.__init__(self)
197 self.setSelectionMode(self.ContiguousSelection)
198 self.setHeaderLabels([N_('Summary'), N_('Author'), N_('Date, Time')])
200 self.sha1map = {}
201 self.notifier = notifier
202 self.selecting = False
203 self.commits = []
205 self.action_up = qtutils.add_action(self, N_('Go Up'), self.go_up,
206 Qt.Key_K)
208 self.action_down = qtutils.add_action(self, N_('Go Down'), self.go_down,
209 Qt.Key_J)
211 notifier.add_observer(COMMITS_SELECTED, self.commits_selected)
213 self.connect(self, SIGNAL('itemSelectionChanged()'),
214 self.selection_changed)
216 # ViewerMixin
217 def go_up(self):
218 self.goto(self.itemAbove)
220 def go_down(self):
221 self.goto(self.itemBelow)
223 def goto(self, finder):
224 items = self.selected_items()
225 item = items and items[0] or None
226 if item is None:
227 return
228 found = finder(item)
229 if found:
230 self.select([found.commit.sha1], block_signals=False)
232 def set_selecting(self, selecting):
233 self.selecting = selecting
235 def selection_changed(self):
236 items = self.selected_items()
237 if not items:
238 return
239 self.set_selecting(True)
240 self.notifier.notify_observers(COMMITS_SELECTED,
241 [i.commit for i in items])
242 self.set_selecting(False)
244 def commits_selected(self, commits):
245 if self.selecting:
246 return
247 self.select([commit.sha1 for commit in commits])
249 def select(self, sha1s, block_signals=True):
250 self.clearSelection()
251 for sha1 in sha1s:
252 try:
253 item = self.sha1map[sha1]
254 except KeyError:
255 continue
256 block = self.blockSignals(block_signals)
257 self.scrollToItem(item)
258 item.setSelected(True)
259 self.blockSignals(block)
261 def adjust_columns(self):
262 width = self.width()-20
263 zero = width*2/3
264 onetwo = width/6
265 self.setColumnWidth(0, zero)
266 self.setColumnWidth(1, onetwo)
267 self.setColumnWidth(2, onetwo)
269 def clear(self):
270 QtGui.QTreeWidget.clear(self)
271 self.sha1map.clear()
272 self.commits = []
274 def add_commits(self, commits):
275 self.commits.extend(commits)
276 items = []
277 for c in reversed(commits):
278 item = CommitTreeWidgetItem(c)
279 items.append(item)
280 self.sha1map[c.sha1] = item
281 for tag in c.tags:
282 self.sha1map[tag] = item
283 self.insertTopLevelItems(0, items)
285 def create_patch(self):
286 items = self.selectedItems()
287 if not items:
288 return
289 items.reverse()
290 sha1s = [item.commit.sha1 for item in items]
291 all_sha1s = [c.sha1 for c in self.commits]
292 cmds.do(cmds.FormatPatch, sha1s, all_sha1s)
294 # Qt overrides
295 def contextMenuEvent(self, event):
296 self.context_menu_event(event)
298 def mousePressEvent(self, event):
299 if event.button() == Qt.RightButton:
300 event.accept()
301 return
302 QtGui.QTreeWidget.mousePressEvent(self, event)
305 class DAGView(MainWindow):
306 """The git-dag widget."""
308 def __init__(self, model, dag, parent=None, args=None):
309 MainWindow.__init__(self, parent)
311 self.setAttribute(Qt.WA_MacMetalStyle)
312 self.setMinimumSize(420, 420)
314 # change when widgets are added/removed
315 self.widget_version = 1
316 self.model = model
317 self.dag = dag
319 self.commits = {}
320 self.commit_list = []
322 self.old_count = None
323 self.old_ref = None
324 self.thread = ReaderThread(dag, self)
326 self.revtext = completion.GitLogLineEdit()
328 self.maxresults = QtGui.QSpinBox()
329 self.maxresults.setMinimum(1)
330 self.maxresults.setMaximum(99999)
331 self.maxresults.setPrefix('')
332 self.maxresults.setSuffix('')
334 self.zoom_out = qt.create_action_button(
335 N_('Zoom Out'), qtutils.theme_icon('zoom-out.png'))
337 self.zoom_in = qt.create_action_button(
338 N_('Zoom In'), qtutils.theme_icon('zoom-in.png'))
340 self.zoom_to_fit = qt.create_action_button(
341 N_('Zoom to Fit'), qtutils.theme_icon('zoom-fit-best.png'))
343 self.notifier = notifier = observable.Observable()
344 self.notifier.refs_updated = refs_updated = 'refs_updated'
345 self.notifier.add_observer(refs_updated, self.display)
347 self.treewidget = CommitTreeWidget(notifier, self)
348 self.diffwidget = DiffWidget(notifier, self)
349 self.graphview = GraphView(notifier, self)
351 self.controls_layout = QtGui.QHBoxLayout()
352 self.controls_layout.setMargin(defs.no_margin)
353 self.controls_layout.setSpacing(defs.spacing)
354 self.controls_layout.addWidget(self.revtext)
355 self.controls_layout.addWidget(self.maxresults)
357 self.controls_widget = QtGui.QWidget()
358 self.controls_widget.setLayout(self.controls_layout)
360 self.log_dock = qt.create_dock(N_('Log'), self, stretch=False)
361 self.log_dock.setWidget(self.treewidget)
362 log_dock_titlebar = self.log_dock.titleBarWidget()
363 log_dock_titlebar.add_corner_widget(self.controls_widget)
365 self.diff_dock = qt.create_dock(N_('Diff'), self)
366 self.diff_dock.setWidget(self.diffwidget)
368 self.graph_controls_layout = QtGui.QHBoxLayout()
369 self.graph_controls_layout.setMargin(defs.no_margin)
370 self.graph_controls_layout.setSpacing(defs.button_spacing)
371 self.graph_controls_layout.addWidget(self.zoom_out)
372 self.graph_controls_layout.addWidget(self.zoom_in)
373 self.graph_controls_layout.addWidget(self.zoom_to_fit)
375 self.graph_controls_widget = QtGui.QWidget()
376 self.graph_controls_widget.setLayout(self.graph_controls_layout)
378 self.graphview_dock = qt.create_dock(N_('Graph'), self)
379 self.graphview_dock.setWidget(self.graphview)
380 graph_titlebar = self.graphview_dock.titleBarWidget()
381 graph_titlebar.add_corner_widget(self.graph_controls_widget)
383 self.lock_layout_action = qtutils.add_action_bool(self,
384 N_('Lock Layout'), self.set_lock_layout, False)
386 # Create the application menu
387 self.menubar = QtGui.QMenuBar(self)
389 # View Menu
390 self.view_menu = create_menu(N_('View'), self.menubar)
391 self.view_menu.addAction(self.log_dock.toggleViewAction())
392 self.view_menu.addAction(self.graphview_dock.toggleViewAction())
393 self.view_menu.addAction(self.diff_dock.toggleViewAction())
394 self.view_menu.addSeparator()
395 self.view_menu.addAction(self.lock_layout_action)
397 self.menubar.addAction(self.view_menu.menuAction())
398 self.setMenuBar(self.menubar)
400 left = Qt.LeftDockWidgetArea
401 right = Qt.RightDockWidgetArea
402 bottom = Qt.BottomDockWidgetArea
403 self.addDockWidget(left, self.log_dock)
404 self.addDockWidget(right, self.graphview_dock)
405 self.addDockWidget(bottom, self.diff_dock)
407 # Update fields affected by model
408 self.revtext.setText(dag.ref)
409 self.maxresults.setValue(dag.count)
410 self.update_window_title()
412 # Also re-loads dag.* from the saved state
413 if not qtutils.apply_state(self):
414 self.resize_to_desktop()
416 qtutils.connect_button(self.zoom_out, self.graphview.zoom_out)
417 qtutils.connect_button(self.zoom_in, self.graphview.zoom_in)
418 qtutils.connect_button(self.zoom_to_fit,
419 self.graphview.zoom_to_fit)
421 self.thread.connect(self.thread, self.thread.commits_ready,
422 self.add_commits)
424 self.thread.connect(self.thread, self.thread.done,
425 self.thread_done)
427 self.connect(self.treewidget, SIGNAL('diff_commits'),
428 self.diff_commits)
430 self.connect(self.graphview, SIGNAL('diff_commits'),
431 self.diff_commits)
433 self.connect(self.maxresults, SIGNAL('editingFinished()'),
434 self.display)
436 self.connect(self.revtext, SIGNAL('changed()'),
437 self.display)
439 self.connect(self.revtext, SIGNAL('textChanged(QString)'),
440 self.text_changed)
442 self.connect(self.revtext, SIGNAL('returnPressed()'),
443 self.display)
445 # The model is updated in another thread so use
446 # signals/slots to bring control back to the main GUI thread
447 self.model.add_observer(self.model.message_updated,
448 self.emit_model_updated)
450 self.connect(self, SIGNAL('model_updated'),
451 self.model_updated)
453 qtutils.add_action(self, 'Focus search field',
454 lambda: self.revtext.setFocus(), 'Ctrl+l')
456 qtutils.add_close_action(self)
458 def text_changed(self, txt):
459 self.dag.ref = unicode(txt)
460 self.update_window_title()
462 def update_window_title(self):
463 project = self.model.project
464 if self.dag.ref:
465 self.setWindowTitle(N_('%s: %s - DAG') % (project, self.dag.ref))
466 else:
467 self.setWindowTitle(project + N_(' - DAG'))
469 def export_state(self):
470 state = self.Mixin.export_state(self)
471 state['count'] = self.dag.count
472 return state
474 def apply_state(self, state):
475 result = self.Mixin.apply_state(self, state)
476 try:
477 count = state['count']
478 if self.dag.overridden('count'):
479 count = self.dag.count
480 except:
481 count = self.dag.count
482 result = False
483 self.dag.set_count(count)
484 self.lock_layout_action.setChecked(state.get('lock_layout', False))
485 return result
487 def emit_model_updated(self):
488 self.emit(SIGNAL('model_updated'))
490 def model_updated(self):
491 if self.dag.ref:
492 self.revtext.update_matches()
493 return
494 if not self.model.currentbranch:
495 return
496 self.revtext.setText(self.model.currentbranch)
497 self.display()
499 def display(self):
500 new_ref = unicode(self.revtext.text())
501 if not new_ref:
502 return
503 new_count = self.maxresults.value()
504 old_ref = self.old_ref
505 old_count = self.old_count
506 if old_ref == new_ref and old_count == new_count:
507 return
509 self.old_ref = new_ref
510 self.old_count = new_count
512 self.thread.stop()
513 self.clear()
514 self.dag.set_ref(new_ref)
515 self.dag.set_count(self.maxresults.value())
516 self.thread.start()
518 def show(self):
519 self.Mixin.show(self)
520 self.treewidget.adjust_columns()
522 def clear(self):
523 self.graphview.clear()
524 self.treewidget.clear()
525 self.commits.clear()
526 self.commit_list = []
528 def add_commits(self, commits):
529 self.commit_list.extend(commits)
530 # Keep track of commits
531 for commit_obj in commits:
532 self.commits[commit_obj.sha1] = commit_obj
533 for tag in commit_obj.tags:
534 self.commits[tag] = commit_obj
535 self.graphview.add_commits(commits)
536 self.treewidget.add_commits(commits)
538 def thread_done(self):
539 self.graphview.setFocus()
540 try:
541 commit_obj = self.commit_list[-1]
542 except IndexError:
543 return
544 self.notifier.notify_observers(COMMITS_SELECTED, [commit_obj])
545 self.graphview.update_scene_rect()
546 self.graphview.set_initial_view()
548 def resize_to_desktop(self):
549 desktop = QtGui.QApplication.instance().desktop()
550 width = desktop.width()
551 height = desktop.height()
552 self.resize(width, height)
554 def diff_commits(self, a, b):
555 paths = self.dag.paths()
556 if paths:
557 difftool.launch([a, b, '--'] + paths)
558 else:
559 difftool.diff_commits(self, a, b)
561 # Qt overrides
562 def closeEvent(self, event):
563 self.revtext.close_popup()
564 self.thread.stop()
565 self.Mixin.closeEvent(self, event)
567 def resizeEvent(self, e):
568 self.Mixin.resizeEvent(self, e)
569 self.treewidget.adjust_columns()
572 class ReaderThread(QtCore.QThread):
573 commits_ready = SIGNAL('commits_ready')
574 done = SIGNAL('done')
576 def __init__(self, dag, parent):
577 QtCore.QThread.__init__(self, parent)
578 self.dag = dag
579 self._abort = False
580 self._stop = False
581 self._mutex = QtCore.QMutex()
582 self._condition = QtCore.QWaitCondition()
584 def run(self):
585 repo = RepoReader(self.dag)
586 repo.reset()
587 commits = []
588 for c in repo:
589 self._mutex.lock()
590 if self._stop:
591 self._condition.wait(self._mutex)
592 self._mutex.unlock()
593 if self._abort:
594 repo.reset()
595 return
596 commits.append(c)
597 if len(commits) >= 512:
598 self.emit(self.commits_ready, commits)
599 commits = []
601 if commits:
602 self.emit(self.commits_ready, commits)
603 self.emit(self.done)
605 def start(self):
606 self._abort = False
607 self._stop = False
608 QtCore.QThread.start(self)
610 def pause(self):
611 self._mutex.lock()
612 self._stop = True
613 self._mutex.unlock()
615 def resume(self):
616 self._mutex.lock()
617 self._stop = False
618 self._mutex.unlock()
619 self._condition.wakeOne()
621 def stop(self):
622 self._abort = True
623 self.wait()
626 class Cache(object):
627 pass
630 class Edge(QtGui.QGraphicsItem):
631 item_type = QtGui.QGraphicsItem.UserType + 1
633 def __init__(self, source, dest):
635 QtGui.QGraphicsItem.__init__(self)
637 self.setAcceptedMouseButtons(Qt.NoButton)
638 self.source = source
639 self.dest = dest
640 self.commit = source.commit
641 self.setZValue(-2)
643 dest_pt = Commit.item_bbox.center()
645 self.source_pt = self.mapFromItem(self.source, dest_pt)
646 self.dest_pt = self.mapFromItem(self.dest, dest_pt)
647 self.line = QtCore.QLineF(self.source_pt, self.dest_pt)
649 width = self.dest_pt.x() - self.source_pt.x()
650 height = self.dest_pt.y() - self.source_pt.y()
651 rect = QtCore.QRectF(self.source_pt, QtCore.QSizeF(width, height))
652 self.bound = rect.normalized()
654 # Choose a new color for new branch edges
655 if self.source.x() < self.dest.x():
656 color = EdgeColor.next()
657 line = Qt.SolidLine
658 elif self.source.x() != self.dest.x():
659 color = EdgeColor.current()
660 line = Qt.SolidLine
661 else:
662 color = EdgeColor.current()
663 line = Qt.SolidLine
665 self.pen = QtGui.QPen(color, 4.0, line, Qt.SquareCap, Qt.RoundJoin)
667 # Qt overrides
668 def type(self):
669 return self.item_type
671 def boundingRect(self):
672 return self.bound
674 def paint(self, painter, option, widget):
676 arc_rect = 10
677 connector_length = 5
679 painter.setPen(self.pen)
680 path = QtGui.QPainterPath()
682 if self.source.x() == self.dest.x():
683 path.moveTo(self.source.x(), self.source.y())
684 path.lineTo(self.dest.x(), self.dest.y())
685 painter.drawPath(path)
687 else:
689 #Define points starting from source
690 point1 = QPointF(self.source.x(), self.source.y())
691 point2 = QPointF(point1.x(), point1.y() - connector_length)
692 point3 = QPointF(point2.x() + arc_rect, point2.y() - arc_rect)
694 #Define points starting from dest
695 point4 = QPointF(self.dest.x(), self.dest.y())
696 point5 = QPointF(point4.x(),point3.y() - arc_rect)
697 point6 = QPointF(point5.x() - arc_rect, point5.y() + arc_rect)
699 start_angle_arc1 = 180
700 span_angle_arc1 = 90
701 start_angle_arc2 = 90
702 span_angle_arc2 = -90
704 # If the dest is at the left of the source, then we
705 # need to reverse some values
706 if self.source.x() > self.dest.x():
707 point5 = QPointF(point4.x(), point4.y() + connector_length)
708 point6 = QPointF(point5.x() + arc_rect, point5.y() + arc_rect)
709 point3 = QPointF(self.source.x() - arc_rect, point6.y())
710 point2 = QPointF(self.source.x(), point3.y() + arc_rect)
712 span_angle_arc1 = 90
714 path.moveTo(point1)
715 path.lineTo(point2)
716 path.arcTo(QRectF(point2, point3),
717 start_angle_arc1, span_angle_arc1)
718 path.lineTo(point6)
719 path.arcTo(QRectF(point6, point5),
720 start_angle_arc2, span_angle_arc2)
721 path.lineTo(point4)
722 painter.drawPath(path)
725 class EdgeColor(object):
726 """An edge color factory"""
728 current_color_index = 0
729 colors = [
730 QtGui.QColor(Qt.red),
731 QtGui.QColor(Qt.green),
732 QtGui.QColor(Qt.blue),
733 QtGui.QColor(Qt.black),
734 QtGui.QColor(Qt.darkRed),
735 QtGui.QColor(Qt.darkGreen),
736 QtGui.QColor(Qt.darkBlue),
737 QtGui.QColor(Qt.cyan),
738 QtGui.QColor(Qt.magenta),
739 # Orange; Qt.yellow is too low-contrast
740 qt.rgba(0xff, 0x66, 0x00),
741 QtGui.QColor(Qt.gray),
742 QtGui.QColor(Qt.darkCyan),
743 QtGui.QColor(Qt.darkMagenta),
744 QtGui.QColor(Qt.darkYellow),
745 QtGui.QColor(Qt.darkGray),
748 @classmethod
749 def next(cls):
750 cls.current_color_index += 1
751 cls.current_color_index %= len(cls.colors)
752 color = cls.colors[cls.current_color_index]
753 color.setAlpha(128)
754 return color
756 @classmethod
757 def current(cls):
758 return cls.colors[cls.current_color_index]
761 class Commit(QtGui.QGraphicsItem):
762 item_type = QtGui.QGraphicsItem.UserType + 2
763 commit_radius = 12.0
764 merge_radius = 18.0
766 item_shape = QtGui.QPainterPath()
767 item_shape.addRect(commit_radius/-2.0,
768 commit_radius/-2.0,
769 commit_radius, commit_radius)
770 item_bbox = item_shape.boundingRect()
772 inner_rect = QtGui.QPainterPath()
773 inner_rect.addRect(commit_radius/-2.0 + 2.0,
774 commit_radius/-2.0 + 2.0,
775 commit_radius - 4.0,
776 commit_radius - 4.0)
777 inner_rect = inner_rect.boundingRect()
779 commit_color = QtGui.QColor(Qt.white)
780 outline_color = commit_color.darker()
781 merge_color = QtGui.QColor(Qt.lightGray)
783 commit_selected_color = QtGui.QColor(Qt.green)
784 selected_outline_color = commit_selected_color.darker()
786 commit_pen = QtGui.QPen()
787 commit_pen.setWidth(1.0)
788 commit_pen.setColor(outline_color)
790 def __init__(self, commit,
791 notifier,
792 selectable=QtGui.QGraphicsItem.ItemIsSelectable,
793 cursor=Qt.PointingHandCursor,
794 xpos=commit_radius/2.0 + 1.0,
795 cached_commit_color=commit_color,
796 cached_merge_color=merge_color):
798 QtGui.QGraphicsItem.__init__(self)
800 self.commit = commit
801 self.notifier = notifier
803 self.setZValue(0)
804 self.setFlag(selectable)
805 self.setCursor(cursor)
806 self.setToolTip(commit.sha1[:7] + ': ' + commit.summary)
808 if commit.tags:
809 self.label = label = Label(commit)
810 label.setParentItem(self)
811 label.setPos(xpos, -self.commit_radius/2.0)
812 else:
813 self.label = None
815 if len(commit.parents) > 1:
816 self.brush = cached_merge_color
817 else:
818 self.brush = cached_commit_color
820 self.pressed = False
821 self.dragged = False
823 def blockSignals(self, blocked):
824 self.notifier.notification_enabled = not blocked
826 def itemChange(self, change, value):
827 if change == QtGui.QGraphicsItem.ItemSelectedHasChanged:
828 # Broadcast selection to other widgets
829 selected_items = self.scene().selectedItems()
830 commits = [item.commit for item in selected_items]
831 self.scene().parent().set_selecting(True)
832 self.notifier.notify_observers(COMMITS_SELECTED, commits)
833 self.scene().parent().set_selecting(False)
835 # Cache the pen for use in paint()
836 if value.toPyObject():
837 self.brush = self.commit_selected_color
838 color = self.selected_outline_color
839 else:
840 if len(self.commit.parents) > 1:
841 self.brush = self.merge_color
842 else:
843 self.brush = self.commit_color
844 color = self.outline_color
845 commit_pen = QtGui.QPen()
846 commit_pen.setWidth(1.0)
847 commit_pen.setColor(color)
848 self.commit_pen = commit_pen
850 return QtGui.QGraphicsItem.itemChange(self, change, value)
852 def type(self):
853 return self.item_type
855 def boundingRect(self, rect=item_bbox):
856 return rect
858 def shape(self):
859 return self.item_shape
861 def paint(self, painter, option, widget,
862 inner=inner_rect,
863 cache=Cache):
865 # Do not draw outside the exposed rect
866 painter.setClipRect(option.exposedRect)
868 # Draw ellipse
869 painter.setPen(self.commit_pen)
870 painter.setBrush(self.brush)
871 painter.drawEllipse(inner)
874 def mousePressEvent(self, event):
875 QtGui.QGraphicsItem.mousePressEvent(self, event)
876 self.pressed = True
877 self.selected = self.isSelected()
879 def mouseMoveEvent(self, event):
880 if self.pressed:
881 self.dragged = True
882 QtGui.QGraphicsItem.mouseMoveEvent(self, event)
884 def mouseReleaseEvent(self, event):
885 QtGui.QGraphicsItem.mouseReleaseEvent(self, event)
886 if (not self.dragged and
887 self.selected and
888 event.button() == Qt.LeftButton):
889 return
890 self.pressed = False
891 self.dragged = False
894 class Label(QtGui.QGraphicsItem):
895 item_type = QtGui.QGraphicsItem.UserType + 3
897 width = 72
898 height = 18
900 item_shape = QtGui.QPainterPath()
901 item_shape.addRect(0, 0, width, height)
902 item_bbox = item_shape.boundingRect()
904 text_options = QtGui.QTextOption()
905 text_options.setAlignment(Qt.AlignCenter)
906 text_options.setAlignment(Qt.AlignVCenter)
908 def __init__(self, commit,
909 other_color=QtGui.QColor(Qt.white),
910 head_color=QtGui.QColor(Qt.green)):
911 QtGui.QGraphicsItem.__init__(self)
912 self.setZValue(-1)
914 # Starts with enough space for two tags. Any more and the commit
915 # needs to be taller to accomodate.
916 self.commit = commit
918 if 'HEAD' in commit.tags:
919 self.color = head_color
920 else:
921 self.color = other_color
923 self.color.setAlpha(180)
924 self.pen = QtGui.QPen()
925 self.pen.setColor(self.color.darker())
926 self.pen.setWidth(1.0)
928 def type(self):
929 return self.item_type
931 def boundingRect(self, rect=item_bbox):
932 return rect
934 def shape(self):
935 return self.item_shape
937 def paint(self, painter, option, widget,
938 text_opts=text_options,
939 black=Qt.black,
940 cache=Cache):
941 try:
942 font = cache.label_font
943 except AttributeError:
944 font = cache.label_font = QtGui.QApplication.font()
945 font.setPointSize(6)
948 # Draw tags
949 painter.setBrush(self.color)
950 painter.setPen(self.pen)
951 painter.setFont(font)
953 current_width = 0
955 for tag in self.commit.tags:
956 text_rect = painter.boundingRect(
957 QRectF(current_width, 0, 0, 0), Qt.TextSingleLine, tag)
958 box_rect = text_rect.adjusted(-1, -1, 1, 1)
959 painter.drawRoundedRect(box_rect, 2, 2)
960 painter.drawText(text_rect, Qt.TextSingleLine, tag)
961 current_width += text_rect.width() + 5
964 class GraphView(ViewerMixin, QtGui.QGraphicsView):
966 x_max = 0
967 y_min = 0
969 x_adjust = Commit.commit_radius*4/3
970 y_adjust = Commit.commit_radius*4/3
972 x_off = 18
973 y_off = 24
975 def __init__(self, notifier, parent):
976 QtGui.QGraphicsView.__init__(self, parent)
977 ViewerMixin.__init__(self)
979 highlight = self.palette().color(QtGui.QPalette.Highlight)
980 Commit.commit_selected_color = highlight
981 Commit.selected_outline_color = highlight.darker()
983 self.selection_list = []
984 self.notifier = notifier
985 self.commits = []
986 self.items = {}
987 self.saved_matrix = QtGui.QMatrix(self.matrix())
989 self.x_offsets = collections.defaultdict(int)
991 self.is_panning = False
992 self.pressed = False
993 self.selecting = False
994 self.last_mouse = [0, 0]
995 self.zoom = 2
996 self.setDragMode(self.RubberBandDrag)
998 scene = QtGui.QGraphicsScene(self)
999 scene.setItemIndexMethod(QtGui.QGraphicsScene.NoIndex)
1000 self.setScene(scene)
1002 self.setRenderHint(QtGui.QPainter.Antialiasing)
1003 self.setViewportUpdateMode(self.BoundingRectViewportUpdate)
1004 self.setCacheMode(QtGui.QGraphicsView.CacheBackground)
1005 self.setTransformationAnchor(QtGui.QGraphicsView.AnchorUnderMouse)
1006 self.setResizeAnchor(QtGui.QGraphicsView.NoAnchor)
1007 self.setBackgroundBrush(QtGui.QColor(Qt.white))
1009 qtutils.add_action(self, N_('Zoom In'),
1010 self.zoom_in, Qt.Key_Plus, Qt.Key_Equal)
1012 qtutils.add_action(self, N_('Zoom Out'),
1013 self.zoom_out, Qt.Key_Minus)
1015 qtutils.add_action(self, N_('Zoom to Fit'),
1016 self.zoom_to_fit, Qt.Key_F)
1018 qtutils.add_action(self, N_('Select Parent'),
1019 self.select_parent, 'Shift+J')
1021 qtutils.add_action(self, N_('Select Oldest Parent'),
1022 self.select_oldest_parent, Qt.Key_J)
1024 qtutils.add_action(self, N_('Select Child'),
1025 self.select_child, 'Shift+K')
1027 qtutils.add_action(self, N_('Select Newest Child'),
1028 self.select_newest_child, Qt.Key_K)
1030 notifier.add_observer(COMMITS_SELECTED, self.commits_selected)
1032 def clear(self):
1033 self.scene().clear()
1034 self.selection_list = []
1035 self.items.clear()
1036 self.x_offsets.clear()
1037 self.x_max = 0
1038 self.y_min = 0
1039 self.commits = []
1041 # ViewerMixin interface
1042 def selected_items(self):
1043 """Return the currently selected items"""
1044 return self.scene().selectedItems()
1046 def zoom_in(self):
1047 self.scale_view(1.5)
1049 def zoom_out(self):
1050 self.scale_view(1.0/1.5)
1052 def commits_selected(self, commits):
1053 if self.selecting:
1054 return
1055 self.select([commit.sha1 for commit in commits])
1057 def select(self, sha1s):
1058 """Select the item for the SHA-1"""
1059 self.scene().clearSelection()
1060 for sha1 in sha1s:
1061 try:
1062 item = self.items[sha1]
1063 except KeyError:
1064 continue
1065 item.blockSignals(True)
1066 item.setSelected(True)
1067 item.blockSignals(False)
1068 item_rect = item.sceneTransform().mapRect(item.boundingRect())
1069 self.ensureVisible(item_rect)
1071 def get_item_by_generation(self, commits, criteria_fn):
1072 """Return the item for the commit matching criteria"""
1073 if not commits:
1074 return None
1075 generation = None
1076 for commit in commits:
1077 if (generation is None or
1078 criteria_fn(generation, commit.generation)):
1079 sha1 = commit.sha1
1080 generation = commit.generation
1081 try:
1082 return self.items[sha1]
1083 except KeyError:
1084 return None
1086 def oldest_item(self, commits):
1087 """Return the item for the commit with the oldest generation number"""
1088 return self.get_item_by_generation(commits, lambda a, b: a > b)
1090 def newest_item(self, commits):
1091 """Return the item for the commit with the newest generation number"""
1092 return self.get_item_by_generation(commits, lambda a, b: a < b)
1094 def create_patch(self):
1095 items = self.selected_items()
1096 if not items:
1097 return
1098 selected_commits = self.sort_by_generation([n.commit for n in items])
1099 sha1s = [c.sha1 for c in selected_commits]
1100 all_sha1s = [c.sha1 for c in self.commits]
1101 cmds.do(cmds.FormatPatch, sha1s, all_sha1s)
1103 def select_parent(self):
1104 """Select the parent with the newest generation number"""
1105 selected_item = self.selected_item()
1106 if selected_item is None:
1107 return
1108 parent_item = self.newest_item(selected_item.commit.parents)
1109 if parent_item is None:
1110 return
1111 selected_item.setSelected(False)
1112 parent_item.setSelected(True)
1113 self.ensureVisible(
1114 parent_item.mapRectToScene(parent_item.boundingRect()))
1116 def select_oldest_parent(self):
1117 """Select the parent with the oldest generation number"""
1118 selected_item = self.selected_item()
1119 if selected_item is None:
1120 return
1121 parent_item = self.oldest_item(selected_item.commit.parents)
1122 if parent_item is None:
1123 return
1124 selected_item.setSelected(False)
1125 parent_item.setSelected(True)
1126 scene_rect = parent_item.mapRectToScene(parent_item.boundingRect())
1127 self.ensureVisible(scene_rect)
1129 def select_child(self):
1130 """Select the child with the oldest generation number"""
1131 selected_item = self.selected_item()
1132 if selected_item is None:
1133 return
1134 child_item = self.oldest_item(selected_item.commit.children)
1135 if child_item is None:
1136 return
1137 selected_item.setSelected(False)
1138 child_item.setSelected(True)
1139 scene_rect = child_item.mapRectToScene(child_item.boundingRect())
1140 self.ensureVisible(scene_rect)
1142 def select_newest_child(self):
1143 """Select the Nth child with the newest generation number (N > 1)"""
1144 selected_item = self.selected_item()
1145 if selected_item is None:
1146 return
1147 if len(selected_item.commit.children) > 1:
1148 children = selected_item.commit.children[1:]
1149 else:
1150 children = selected_item.commit.children
1151 child_item = self.newest_item(children)
1152 if child_item is None:
1153 return
1154 selected_item.setSelected(False)
1155 child_item.setSelected(True)
1156 scene_rect = child_item.mapRectToScene(child_item.boundingRect())
1157 self.ensureVisible(scene_rect)
1159 def set_initial_view(self):
1160 self_commits = self.commits
1161 self_items = self.items
1163 commits = self_commits[-2:]
1164 items = [self_items[c.sha1] for c in commits]
1165 self.fit_view_to_items(items)
1167 def zoom_to_fit(self):
1168 """Fit selected items into the viewport"""
1170 items = self.selected_items()
1171 self.fit_view_to_items(items)
1173 def fit_view_to_items(self, items):
1174 if not items:
1175 rect = self.scene().itemsBoundingRect()
1176 else:
1177 x_min = sys.maxint
1178 y_min = sys.maxint
1179 x_max = -sys.maxint
1180 ymax = -sys.maxint
1181 for item in items:
1182 pos = item.pos()
1183 item_rect = item.boundingRect()
1184 x_off = item_rect.width()
1185 y_off = item_rect.height()
1186 x_min = min(x_min, pos.x())
1187 y_min = min(y_min, pos.y()-y_off)
1188 x_max = max(x_max, pos.x()+x_off)
1189 ymax = max(ymax, pos.y())
1190 rect = QtCore.QRectF(x_min, y_min, x_max-x_min, ymax-y_min)
1191 x_adjust = GraphView.x_adjust
1192 y_adjust = GraphView.y_adjust
1193 rect.setX(rect.x() - x_adjust)
1194 rect.setY(rect.y() - y_adjust)
1195 rect.setHeight(rect.height() + y_adjust*2)
1196 rect.setWidth(rect.width() + x_adjust*2)
1197 self.fitInView(rect, Qt.KeepAspectRatio)
1198 self.scene().invalidate()
1200 def save_selection(self, event):
1201 if event.button() != Qt.LeftButton:
1202 return
1203 elif Qt.ShiftModifier != event.modifiers():
1204 return
1205 self.selection_list = self.selected_items()
1207 def restore_selection(self, event):
1208 if Qt.ShiftModifier != event.modifiers():
1209 return
1210 for item in self.selection_list:
1211 item.setSelected(True)
1213 def handle_event(self, event_handler, event):
1214 self.update()
1215 self.save_selection(event)
1216 event_handler(self, event)
1217 self.restore_selection(event)
1219 def set_selecting(self, selecting):
1220 self.selecting = selecting
1222 def pan(self, event):
1223 pos = event.pos()
1224 dx = pos.x() - self.mouse_start[0]
1225 dy = pos.y() - self.mouse_start[1]
1227 if dx == 0 and dy == 0:
1228 return
1230 rect = QtCore.QRect(0, 0, abs(dx), abs(dy))
1231 delta = self.mapToScene(rect).boundingRect()
1233 tx = delta.width()
1234 if dx < 0.0:
1235 tx = -tx
1237 ty = delta.height()
1238 if dy < 0.0:
1239 ty = -ty
1241 matrix = QtGui.QMatrix(self.saved_matrix).translate(tx, ty)
1242 self.setTransformationAnchor(QtGui.QGraphicsView.NoAnchor)
1243 self.setMatrix(matrix)
1245 def wheel_zoom(self, event):
1246 """Handle mouse wheel zooming."""
1247 zoom = math.pow(2.0, event.delta()/512.0)
1248 factor = (self.matrix()
1249 .scale(zoom, zoom)
1250 .mapRect(QtCore.QRectF(0.0, 0.0, 1.0, 1.0))
1251 .width())
1252 if factor < 0.014 or factor > 42.0:
1253 return
1254 self.setTransformationAnchor(QtGui.QGraphicsView.AnchorUnderMouse)
1255 self.zoom = zoom
1256 self.scale(zoom, zoom)
1258 def wheel_pan(self, event):
1259 """Handle mouse wheel panning."""
1261 if event.delta() < 0:
1262 s = -133.0
1263 else:
1264 s = 133.0
1265 pan_rect = QtCore.QRectF(0.0, 0.0, 1.0, 1.0)
1266 factor = 1.0/self.matrix().mapRect(pan_rect).width()
1268 if event.orientation() == Qt.Vertical:
1269 matrix = self.matrix().translate(0, s*factor)
1270 else:
1271 matrix = self.matrix().translate(s*factor, 0)
1272 self.setTransformationAnchor(QtGui.QGraphicsView.NoAnchor)
1273 self.setMatrix(matrix)
1275 def scale_view(self, scale):
1276 factor = (self.matrix().scale(scale, scale)
1277 .mapRect(QtCore.QRectF(0, 0, 1, 1))
1278 .width())
1279 if factor < 0.07 or factor > 100.0:
1280 return
1281 self.zoom = scale
1283 adjust_scrollbars = True
1284 scrollbar = self.verticalScrollBar()
1285 if scrollbar:
1286 value = scrollbar.value()
1287 min_ = scrollbar.minimum()
1288 max_ = scrollbar.maximum()
1289 range_ = max_ - min_
1290 distance = value - min_
1291 nonzero_range = float(range_) > 0.1
1292 if nonzero_range:
1293 scrolloffset = distance/float(range_)
1294 else:
1295 adjust_scrollbars = False
1297 self.setTransformationAnchor(QtGui.QGraphicsView.NoAnchor)
1298 self.scale(scale, scale)
1300 scrollbar = self.verticalScrollBar()
1301 if scrollbar and adjust_scrollbars:
1302 min_ = scrollbar.minimum()
1303 max_ = scrollbar.maximum()
1304 range_ = max_ - min_
1305 value = min_ + int(float(range_) * scrolloffset)
1306 scrollbar.setValue(value)
1308 def add_commits(self, commits):
1309 """Traverse commits and add them to the view."""
1310 self.commits.extend(commits)
1311 scene = self.scene()
1312 for commit in commits:
1313 item = Commit(commit, self.notifier)
1314 self.items[commit.sha1] = item
1315 for ref in commit.tags:
1316 self.items[ref] = item
1317 scene.addItem(item)
1319 self.layout_commits(commits)
1320 self.link(commits)
1322 def link(self, commits):
1323 """Create edges linking commits with their parents"""
1324 scene = self.scene()
1325 for commit in commits:
1326 try:
1327 commit_item = self.items[commit.sha1]
1328 except KeyError:
1329 # TODO - Handle truncated history viewing
1330 continue
1331 for parent in reversed(commit.parents):
1332 try:
1333 parent_item = self.items[parent.sha1]
1334 except KeyError:
1335 # TODO - Handle truncated history viewing
1336 continue
1337 edge = Edge(parent_item, commit_item)
1338 scene.addItem(edge)
1340 def layout_commits(self, nodes):
1341 positions = self.position_nodes(nodes)
1342 for sha1, (x, y) in positions.items():
1343 item = self.items[sha1]
1344 item.setPos(x, y)
1346 def position_nodes(self, nodes):
1347 positions = {}
1349 x_max = self.x_max
1350 y_min = self.y_min
1351 x_off = self.x_off
1352 y_off = self.y_off
1353 x_offsets = self.x_offsets
1355 for node in nodes:
1356 generation = node.generation
1357 sha1 = node.sha1
1359 if node.is_fork():
1360 # This is a fan-out so sweep over child generations and
1361 # shift them to the right to avoid overlapping edges
1362 child_gens = [c.generation for c in node.children]
1363 maxgen = max(child_gens)
1364 for g in xrange(generation + 1, maxgen):
1365 x_offsets[g] += x_off
1367 if len(node.parents) == 1:
1368 # Align nodes relative to their parents
1369 parent_gen = node.parents[0].generation
1370 parent_off = x_offsets[parent_gen]
1371 x_offsets[generation] = max(parent_off-x_off,
1372 x_offsets[generation])
1374 cur_xoff = x_offsets[generation]
1375 next_xoff = cur_xoff
1376 next_xoff += x_off
1377 x_offsets[generation] = next_xoff
1379 x_pos = cur_xoff
1380 y_pos = -generation * y_off
1382 y_pos = min(y_pos, y_min - y_off)
1384 #y_pos = y_off
1385 positions[sha1] = (x_pos, y_pos)
1387 x_max = max(x_max, x_pos)
1388 y_min = y_pos
1390 self.x_max = x_max
1391 self.y_min = y_min
1393 return positions
1395 def update_scene_rect(self):
1396 y_min = self.y_min
1397 x_max = self.x_max
1398 self.scene().setSceneRect(-GraphView.x_adjust,
1399 y_min-GraphView.y_adjust,
1400 x_max + GraphView.x_adjust,
1401 abs(y_min) + GraphView.y_adjust)
1403 def sort_by_generation(self, commits):
1404 if len(commits) < 2:
1405 return commits
1406 commits.sort(cmp=lambda a, b: cmp(a.generation, b.generation))
1407 return commits
1409 # Qt overrides
1410 def contextMenuEvent(self, event):
1411 self.context_menu_event(event)
1413 def mousePressEvent(self, event):
1414 if event.button() == Qt.MidButton:
1415 pos = event.pos()
1416 self.mouse_start = [pos.x(), pos.y()]
1417 self.saved_matrix = QtGui.QMatrix(self.matrix())
1418 self.is_panning = True
1419 return
1420 if event.button() == Qt.RightButton:
1421 event.ignore()
1422 return
1423 if event.button() == Qt.LeftButton:
1424 self.pressed = True
1425 self.handle_event(QtGui.QGraphicsView.mousePressEvent, event)
1427 def mouseMoveEvent(self, event):
1428 pos = self.mapToScene(event.pos())
1429 if self.is_panning:
1430 self.pan(event)
1431 return
1432 self.last_mouse[0] = pos.x()
1433 self.last_mouse[1] = pos.y()
1434 self.handle_event(QtGui.QGraphicsView.mouseMoveEvent, event)
1436 def mouseReleaseEvent(self, event):
1437 self.pressed = False
1438 if event.button() == Qt.MidButton:
1439 self.is_panning = False
1440 return
1441 self.handle_event(QtGui.QGraphicsView.mouseReleaseEvent, event)
1442 self.selection_list = []
1444 def wheelEvent(self, event):
1445 """Handle Qt mouse wheel events."""
1446 if event.modifiers() == Qt.ControlModifier:
1447 self.wheel_zoom(event)
1448 else:
1449 self.wheel_pan(event)