dag: Drastically speedup rendering performance
[git-cola.git] / cola / dag / view.py
blobd060c5eb2e3fe121642bd5351278fd01c296e6fc
1 import collections
2 import sys
3 import math
5 from PyQt4 import QtGui
6 from PyQt4 import QtCore
7 from PyQt4.QtCore import SIGNAL
9 import cola
10 from cola import observable
11 from cola import qtutils
12 from cola import signals
13 from cola import gitcmds
14 from cola import difftool
15 from cola.dag.model import archive
16 from cola.dag.model import RepoReader
17 from cola.prefs import diff_font
18 from cola.qt import DiffSyntaxHighlighter
19 from cola.qt import GitLogLineEdit
20 from cola.widgets import defs
21 from cola.widgets import standard
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
28 class DiffWidget(QtGui.QWidget):
29 def __init__(self, notifier, parent=None):
30 QtGui.QWidget.__init__(self, parent)
32 self.diff = QtGui.QTextEdit()
33 self.diff.setLineWrapMode(QtGui.QTextEdit.NoWrap)
34 self.diff.setReadOnly(True)
35 self.diff.setFont(diff_font())
36 self.highlighter = DiffSyntaxHighlighter(self.diff.document())
38 self.main_layout = QtGui.QHBoxLayout()
39 self.main_layout.addWidget(self.diff)
40 self.main_layout.setMargin(0)
41 self.main_layout.setSpacing(defs.spacing)
42 self.setLayout(self.main_layout)
44 sig = signals.commits_selected
45 notifier.add_observer(sig, self.commits_selected)
47 def commits_selected(self, commits):
48 if len(commits) != 1:
49 return
50 commit = commits[0]
51 sha1 = commit.sha1
52 merge = len(commit.parents) > 1
53 self.diff.setText(gitcmds.diff_info(sha1, merge=merge))
54 qtutils.set_clipboard(sha1)
57 class CommitTreeWidgetItem(QtGui.QTreeWidgetItem):
58 def __init__(self, commit, parent=None):
59 QtGui.QListWidgetItem.__init__(self, parent)
60 self.commit = commit
61 self.setText(0, commit.summary)
62 self.setText(1, commit.author)
63 self.setText(2, commit.authdate)
66 class CommitTreeWidget(QtGui.QTreeWidget):
67 def __init__(self, notifier, parent=None):
68 QtGui.QTreeWidget.__init__(self, parent)
69 self.setSelectionMode(self.ContiguousSelection)
70 self.setUniformRowHeights(True)
71 self.setAllColumnsShowFocus(True)
72 self.setAlternatingRowColors(True)
73 self.setRootIsDecorated(False)
74 self.setHeaderLabels(['Summary', 'Author', 'Date'])
76 self.sha1map = {}
77 self.notifier = notifier
78 self.selecting = False
79 self.commits = []
80 self.clicked = None
81 self.selected = None
82 self.menu_actions = context_menu_actions(self)
84 self.action_up = qtutils.add_action(self, 'Go Up', self.go_up,
85 QtCore.Qt.Key_K)
87 self.action_down = qtutils.add_action(self, 'Go Down', self.go_down,
88 QtCore.Qt.Key_J)
90 sig = signals.commits_selected
91 notifier.add_observer(sig, self.commits_selected)
93 self.connect(self, SIGNAL('itemSelectionChanged()'),
94 self.selection_changed)
96 def contextMenuEvent(self, event):
97 update_menu_actions(self, event)
98 context_menu_event(self, event)
100 def mousePressEvent(self, event):
101 if event.buttons() == QtCore.Qt.RightButton:
102 event.accept()
103 return
104 if event.modifiers() == QtCore.Qt.MetaModifier:
105 event.accept()
106 return
107 super(CommitTreeWidget, self).mousePressEvent(event)
109 def go_up(self):
110 self.goto(self.itemAbove)
112 def go_down(self):
113 self.goto(self.itemBelow)
115 def goto(self, finder):
116 items = self.selectedItems()
117 item = items and items[0] or None
118 if item is None:
119 return
120 found = finder(item)
121 if found:
122 self.select([found.commit.sha1], block_signals=False)
124 def set_selecting(self, selecting):
125 self.selecting = selecting
127 def selection_changed(self):
128 items = self.selectedItems()
129 if not items:
130 return
131 self.set_selecting(True)
132 sig = signals.commits_selected
133 self.notifier.notify_observers(sig, [i.commit for i in items])
134 self.set_selecting(False)
136 def commits_selected(self, commits):
137 if self.selecting:
138 return
139 self.select([commit.sha1 for commit in commits])
141 def select(self, sha1s, block_signals=True):
142 self.clearSelection()
143 for sha1 in sha1s:
144 try:
145 item = self.sha1map[sha1]
146 except KeyError:
147 continue
148 block = self.blockSignals(block_signals)
149 self.scrollToItem(item)
150 item.setSelected(True)
151 self.blockSignals(block)
153 def adjust_columns(self):
154 width = self.width()-20
155 zero = width*2/3
156 onetwo = width/6
157 self.setColumnWidth(0, zero)
158 self.setColumnWidth(1, onetwo)
159 self.setColumnWidth(2, onetwo)
161 def clear(self):
162 QtGui.QTreeWidget.clear(self)
163 self.sha1map.clear()
164 self.commits = []
166 def add_commits(self, commits):
167 self.commits.extend(commits)
168 items = []
169 for c in reversed(commits):
170 item = CommitTreeWidgetItem(c)
171 items.append(item)
172 self.sha1map[c.sha1] = item
173 for tag in c.tags:
174 self.sha1map[tag] = item
175 self.insertTopLevelItems(0, items)
177 def diff_this_selected(self):
178 clicked_sha1 = self.clicked.commit.sha1
179 selected_sha1 = self.selected.commit.sha1
180 self.emit(SIGNAL('diff_commits'), clicked_sha1, selected_sha1)
182 def diff_selected_this(self):
183 clicked_sha1 = self.clicked.commit.sha1
184 selected_sha1 = self.selected.commit.sha1
185 self.emit(SIGNAL('diff_commits'), selected_sha1, clicked_sha1)
187 def create_patch(self):
188 items = self.selectedItems()
189 if not items:
190 return
191 items.reverse()
192 sha1s = [item.commit.sha1 for item in items]
193 all_sha1s = [c.sha1 for c in self.commits]
194 cola.notifier().broadcast(signals.format_patch, sha1s, all_sha1s)
196 def create_branch(self):
197 sha1 = self.clicked.commit.sha1
198 create_new_branch(revision=sha1)
200 def create_tag(self):
201 sha1 = self.clicked.commit.sha1
202 create_tag(revision=sha1)
204 def cherry_pick(self):
205 sha1 = self.clicked.commit.sha1
206 cola.notifier().broadcast(signals.cherry_pick, [sha1])
209 class DAGView(standard.Widget):
210 """The git-dag widget."""
212 def __init__(self, model, dag, parent=None, args=None):
213 super(DAGView, self).__init__(parent)
214 self.setAttribute(QtCore.Qt.WA_MacMetalStyle)
215 self.setMinimumSize(1, 1)
217 # change when widgets are added/removed
218 self.widget_version = 1
219 self.model = model
220 self.dag = dag
222 self.commits = {}
223 self.commit_list = []
225 self.old_count = None
226 self.old_ref = None
228 self.revtext = GitLogLineEdit(parent=self)
230 self.maxresults = QtGui.QSpinBox()
231 self.maxresults.setMinimum(1)
232 self.maxresults.setMaximum(99999)
233 self.maxresults.setPrefix('git log -')
234 self.maxresults.setSuffix('')
236 self.displaybutton = QtGui.QPushButton()
237 self.displaybutton.setText('Display')
239 self.zoom_in = QtGui.QPushButton()
240 self.zoom_in.setIcon(qtutils.theme_icon('zoom-in.png'))
241 self.zoom_in.setFlat(True)
243 self.zoom_out = QtGui.QPushButton()
244 self.zoom_out.setIcon(qtutils.theme_icon('zoom-out.png'))
245 self.zoom_out.setFlat(True)
247 self.top_layout = QtGui.QHBoxLayout()
248 self.top_layout.setMargin(defs.margin)
249 self.top_layout.setSpacing(defs.button_spacing)
251 self.top_layout.addWidget(self.maxresults)
252 self.top_layout.addWidget(self.revtext)
253 self.top_layout.addWidget(self.displaybutton)
254 self.top_layout.addStretch()
255 self.top_layout.addWidget(self.zoom_out)
256 self.top_layout.addWidget(self.zoom_in)
258 self.notifier = notifier = observable.Observable()
259 self.notifier.refs_updated = refs_updated = 'refs_updated'
260 self.notifier.add_observer(refs_updated, self.display)
262 self.graphview = GraphView(notifier)
263 self.treewidget = CommitTreeWidget(notifier)
264 self.diffwidget = DiffWidget(notifier)
266 for signal in (archive,):
267 qtutils.relay_signal(self, self.graphview, SIGNAL(signal))
268 qtutils.relay_signal(self, self.treewidget, SIGNAL(signal))
270 self.splitter = QtGui.QSplitter()
271 self.splitter.setOrientation(QtCore.Qt.Horizontal)
272 self.splitter.setChildrenCollapsible(True)
273 self.splitter.setHandleWidth(defs.handle_width)
275 self.left_splitter = QtGui.QSplitter()
276 self.left_splitter.setOrientation(QtCore.Qt.Vertical)
277 self.left_splitter.setChildrenCollapsible(True)
278 self.left_splitter.setHandleWidth(defs.handle_width)
279 self.left_splitter.setStretchFactor(0, 1)
280 self.left_splitter.setStretchFactor(1, 1)
281 self.left_splitter.insertWidget(0, self.treewidget)
282 self.left_splitter.insertWidget(1, self.diffwidget)
284 self.splitter.insertWidget(0, self.left_splitter)
285 self.splitter.insertWidget(1, self.graphview)
287 self.splitter.setStretchFactor(0, 1)
288 self.splitter.setStretchFactor(1, 1)
290 self.main_layout = layout = QtGui.QVBoxLayout()
291 layout.setMargin(0)
292 layout.setSpacing(0)
293 layout.addLayout(self.top_layout)
294 layout.addWidget(self.splitter)
295 self.setLayout(layout)
297 # Also re-loads dag.* from the saved state
298 if not qtutils.apply_state(self):
299 self.resize_to_desktop()
301 # Update fields affected by model
302 self.revtext.setText(dag.ref)
303 self.maxresults.setValue(dag.count)
304 self.update_window_title()
306 self.thread = ReaderThread(self, dag)
308 self.thread.connect(self.thread, self.thread.commits_ready,
309 self.add_commits)
311 self.thread.connect(self.thread, self.thread.done,
312 self.thread_done)
314 self.connect(self.splitter, SIGNAL('splitterMoved(int,int)'),
315 self.splitter_moved)
317 self.connect(self.zoom_in, SIGNAL('pressed()'),
318 self.graphview.zoom_in)
320 self.connect(self.zoom_out, SIGNAL('pressed()'),
321 self.graphview.zoom_out)
323 self.connect(self.treewidget, SIGNAL('diff_commits'),
324 self.diff_commits)
326 self.connect(self.graphview, SIGNAL('diff_commits'),
327 self.diff_commits)
329 self.connect(self.maxresults, SIGNAL('editingFinished()'),
330 self.display)
332 self.connect(self.displaybutton, SIGNAL('pressed()'),
333 self.display)
335 self.connect(self.revtext, SIGNAL('ref_changed'),
336 self.display)
338 self.connect(self.revtext, SIGNAL('textChanged(QString)'),
339 self.text_changed)
341 # The model is updated in another thread so use
342 # signals/slots to bring control back to the main GUI thread
343 self.model.add_observer(self.model.message_updated,
344 self.emit_model_updated)
346 self.connect(self, SIGNAL('model_updated'),
347 self.model_updated)
349 qtutils.add_close_action(self)
351 def text_changed(self, txt):
352 self.dag.ref = unicode(txt)
353 self.update_window_title()
355 def update_window_title(self):
356 project = self.model.project
357 if self.dag.ref:
358 self.setWindowTitle('%s: %s' % (project, self.dag.ref))
359 else:
360 self.setWindowTitle(project)
362 def export_state(self):
363 state = super(DAGView, self).export_state()
364 state['count'] = self.dag.count
365 return state
367 def apply_state(self, state):
368 try:
369 super(DAGView, self).apply_state(state)
370 except:
371 pass
372 try:
373 count = state['count']
374 except KeyError:
375 pass
376 else:
377 if not self.dag.overridden('count'):
378 self.dag.set_count(count)
380 def emit_model_updated(self):
381 self.emit(SIGNAL('model_updated'))
383 def model_updated(self):
384 if self.dag.ref:
385 self.revtext.update_matches()
386 return
387 if not self.model.currentbranch:
388 return
389 self.revtext.setText(self.model.currentbranch)
390 self.display()
392 def display(self):
393 new_ref = unicode(self.revtext.text())
394 if not new_ref:
395 return
396 new_count = self.maxresults.value()
397 old_ref = self.old_ref
398 old_count = self.old_count
399 if old_ref == new_ref and old_count == new_count:
400 return
402 self.setEnabled(False)
404 self.old_ref = new_ref
405 self.old_count = new_count
407 self.stop()
408 self.clear()
409 self.dag.set_ref(new_ref)
410 self.dag.set_count(self.maxresults.value())
411 self.start()
413 def show(self):
414 super(DAGView, self).show()
415 self.splitter.setSizes([self.width()/2, self.width()/2])
416 self.left_splitter.setSizes([self.height()/3, self.height()*2/3])
417 self.treewidget.adjust_columns()
419 def resizeEvent(self, e):
420 super(DAGView, self).resizeEvent(e)
421 self.treewidget.adjust_columns()
423 def splitter_moved(self, pos, idx):
424 self.treewidget.adjust_columns()
426 def clear(self):
427 self.graphview.clear()
428 self.treewidget.clear()
429 self.commits.clear()
430 self.commit_list = []
432 def add_commits(self, commits):
433 self.commit_list.extend(commits)
434 # Keep track of commits
435 for commit_obj in commits:
436 self.commits[commit_obj.sha1] = commit_obj
437 for tag in commit_obj.tags:
438 self.commits[tag] = commit_obj
439 self.graphview.add_commits(commits)
440 self.treewidget.add_commits(commits)
442 def thread_done(self):
443 self.setEnabled(True)
444 try:
445 commit_obj = self.commit_list[-1]
446 except IndexError:
447 return
448 sig = signals.commits_selected
449 self.notifier.notify_observers(sig, [commit_obj])
450 self.graphview.update_scene_rect()
451 self.graphview.view_fit()
453 def closeEvent(self, event):
454 self.revtext.close_popup()
455 self.stop()
456 qtutils.save_state(self)
457 return super(DAGView, self).closeEvent(event)
459 def pause(self):
460 self.thread.mutex.lock()
461 self.thread.stop = True
462 self.thread.mutex.unlock()
464 def stop(self):
465 self.thread.abort = True
466 self.thread.wait()
468 def start(self):
469 self.thread.abort = False
470 self.thread.stop = False
471 self.thread.start()
473 def resume(self):
474 self.thread.mutex.lock()
475 self.thread.stop = False
476 self.thread.mutex.unlock()
477 self.thread.condition.wakeOne()
479 def resize_to_desktop(self):
480 desktop = QtGui.QApplication.instance().desktop()
481 width = desktop.width()
482 height = desktop.height()
483 self.resize(width, height)
485 def diff_commits(self, a, b):
486 paths = self.dag.paths()
487 if paths:
488 difftool.launch([a, b, '--'] + paths)
489 else:
490 difftool.diff_commits(self, a, b)
493 class ReaderThread(QtCore.QThread):
495 commits_ready = SIGNAL('commits_ready')
496 done = SIGNAL('done')
498 def __init__(self, parent, dag):
499 QtCore.QThread.__init__(self, parent)
500 self.dag = dag
501 self.abort = False
502 self.stop = False
503 self.mutex = QtCore.QMutex()
504 self.condition = QtCore.QWaitCondition()
506 def run(self):
507 repo = RepoReader(self.dag)
508 repo.reset()
509 commits = []
510 for c in repo:
511 self.mutex.lock()
512 if self.stop:
513 self.condition.wait(self.mutex)
514 self.mutex.unlock()
515 if self.abort:
516 repo.reset()
517 return
518 commits.append(c)
519 if len(commits) >= 512:
520 self.emit(self.commits_ready, commits)
521 commits = []
523 if commits:
524 self.emit(self.commits_ready, commits)
525 self.emit(self.done)
528 class Cache(object):
529 pass
532 class Edge(QtGui.QGraphicsItem):
533 item_type = QtGui.QGraphicsItem.UserType + 1
534 arrow_size = 2.0
535 arrow_extra = (arrow_size+1.0)/2.0
537 pen = QtGui.QPen(QtCore.Qt.gray, 1.0,
538 QtCore.Qt.DotLine,
539 QtCore.Qt.SquareCap,
540 QtCore.Qt.BevelJoin)
542 def __init__(self, source, dest,
543 extra=arrow_extra,
544 arrow_size=arrow_size):
546 QtGui.QGraphicsItem.__init__(self)
548 self.setAcceptedMouseButtons(QtCore.Qt.NoButton)
549 self.source = source
550 self.dest = dest
551 self.setZValue(-2)
553 dest_pt = Commit.item_bbox.center()
555 self.source_pt = self.mapFromItem(self.source, dest_pt)
556 self.dest_pt = self.mapFromItem(self.dest, dest_pt)
557 self.line = QtCore.QLineF(self.source_pt, self.dest_pt)
559 width = self.dest_pt.x() - self.source_pt.x()
560 height = self.dest_pt.y() - self.source_pt.y()
561 rect = QtCore.QRectF(self.source_pt, QtCore.QSizeF(width, height))
562 self.bound = rect.normalized().adjusted(-extra, -extra, extra, extra)
564 def type(self):
565 return self.item_type
567 def boundingRect(self):
568 return self.bound
570 def paint(self, painter, option, widget,
571 arrow_size=arrow_size,
572 gray=QtCore.Qt.gray):
573 # Draw the line
574 painter.setPen(self.pen)
575 painter.drawLine(self.line)
578 class Commit(QtGui.QGraphicsItem):
579 item_type = QtGui.QGraphicsItem.UserType + 2
580 width = 46.
581 height = 24.
583 item_shape = QtGui.QPainterPath()
584 item_shape.addRect(width/-2., height/-2., width, height)
585 item_bbox = item_shape.boundingRect()
587 inner_rect = QtGui.QPainterPath()
588 inner_rect.addRect(width/-2.+2., height/-2.+2, width-4., height-4.)
589 inner_rect = inner_rect.boundingRect()
591 selected_color = QtGui.QColor.fromRgb(255, 255, 0)
592 outline_color = QtGui.QColor.fromRgb(64, 96, 192)
595 text_options = QtGui.QTextOption()
596 text_options.setAlignment(QtCore.Qt.AlignCenter)
598 commit_pen = QtGui.QPen()
599 commit_pen.setWidth(1.0)
600 commit_pen.setColor(outline_color)
602 cached_commit_color = QtGui.QColor.fromRgb(128, 222, 255)
603 cached_commit_selected_color = QtGui.QColor.fromRgb(32, 64, 255)
604 cached_merge_color = QtGui.QColor.fromRgb(255, 255, 255)
606 def __init__(self, commit,
607 notifier,
608 selectable=QtGui.QGraphicsItem.ItemIsSelectable,
609 cursor=QtCore.Qt.PointingHandCursor,
610 xpos=width/2. + 1.,
611 commit_color=cached_commit_color,
612 commit_selected_color=cached_commit_selected_color,
613 merge_color=cached_merge_color):
615 QtGui.QGraphicsItem.__init__(self)
617 self.setZValue(0)
618 self.setFlag(selectable)
619 self.setCursor(cursor)
621 self.commit = commit
622 self.notifier = notifier
624 if commit.tags:
625 self.label = label = Label(commit)
626 label.setParentItem(self)
627 label.setPos(xpos, 0.)
628 else:
629 self.label = None
631 if len(commit.parents) > 1:
632 self.commit_color = merge_color
633 else:
634 self.commit_color = commit_color
635 self.text_pen = QtCore.Qt.black
636 self.sha1_text = commit.sha1[:8]
638 self.pressed = False
639 self.dragged = False
642 # Overridden Qt methods
645 def blockSignals(self, blocked):
646 self.notifier.notification_enabled = not blocked
648 def itemChange(self, change, value):
649 if change == QtGui.QGraphicsItem.ItemSelectedHasChanged:
650 # Broadcast selection to other widgets
651 selected_items = self.scene().selectedItems()
652 commits = [item.commit for item in selected_items]
653 self.scene().parent().set_selecting(True)
654 sig = signals.commits_selected
655 self.notifier.notify_observers(sig, commits)
656 self.scene().parent().set_selecting(False)
658 # Cache the pen for use in paint()
659 if value.toPyObject():
660 self.commit_color = self.cached_commit_selected_color
661 self.text_pen = QtCore.Qt.white
662 color = self.selected_color
663 else:
664 self.text_pen = QtCore.Qt.black
665 if len(self.commit.parents) > 1:
666 self.commit_color = self.cached_merge_color
667 else:
668 self.commit_color = self.cached_commit_color
669 color = self.outline_color
670 commit_pen = QtGui.QPen()
671 commit_pen.setWidth(1.0)
672 commit_pen.setColor(color)
673 self.commit_pen = commit_pen
675 return QtGui.QGraphicsItem.itemChange(self, change, value)
677 def type(self):
678 return self.item_type
680 def boundingRect(self, rect=item_bbox):
681 return rect
683 def shape(self):
684 return self.item_shape
686 def paint(self, painter, option, widget,
687 inner=inner_rect,
688 text_opts=text_options,
689 cache=Cache):
691 # Do not draw outside the exposed rect
692 painter.setClipRect(option.exposedRect)
694 # Draw ellipse
695 painter.setPen(self.commit_pen)
696 painter.setBrush(self.commit_color)
697 painter.drawEllipse(inner)
699 # Draw text
700 try:
701 font = cache.font
702 except AttributeError:
703 font = cache.font = painter.font()
704 font.setPointSize(5)
705 painter.setFont(font)
706 painter.setPen(self.text_pen)
707 painter.drawText(inner, self.sha1_text, text_opts)
709 def mousePressEvent(self, event):
710 QtGui.QGraphicsItem.mousePressEvent(self, event)
711 self.pressed = True
712 self.selected = self.isSelected()
714 def mouseMoveEvent(self, event):
715 if self.pressed:
716 self.dragged = True
717 QtGui.QGraphicsItem.mouseMoveEvent(self, event)
719 def mouseReleaseEvent(self, event):
720 QtGui.QGraphicsItem.mouseReleaseEvent(self, event)
721 if (not self.dragged and
722 self.selected and
723 event.button() == QtCore.Qt.LeftButton):
724 return
725 self.pressed = False
726 self.dragged = False
729 class Label(QtGui.QGraphicsItem):
730 item_type = QtGui.QGraphicsItem.UserType + 3
732 width = 72
733 height = 18
735 item_shape = QtGui.QPainterPath()
736 item_shape.addRect(0, 0, width, height)
737 item_bbox = item_shape.boundingRect()
739 text_options = QtGui.QTextOption()
740 text_options.setAlignment(QtCore.Qt.AlignCenter)
741 text_options.setAlignment(QtCore.Qt.AlignVCenter)
743 def __init__(self, commit,
744 other_color=QtGui.QColor.fromRgb(255, 255, 64),
745 head_color=QtGui.QColor.fromRgb(64, 255, 64)):
746 QtGui.QGraphicsItem.__init__(self)
747 self.setZValue(-1)
749 # Starts with enough space for two tags. Any more and the commit
750 # needs to be taller to accomodate.
751 self.commit = commit
752 height = len(commit.tags) * self.height/2. + 4. # +6 padding
754 self.label_box = QtCore.QRectF(0., -height/2., self.width, height)
755 self.text_box = QtCore.QRectF(2., -height/2., self.width-4., height)
756 self.tag_text = '\n'.join(commit.tags)
758 if 'HEAD' in commit.tags:
759 self.color = head_color
760 else:
761 self.color = other_color
763 self.pen = QtGui.QPen()
764 self.pen.setColor(self.color.darker())
765 self.pen.setWidth(1.0)
767 def type(self):
768 return self.item_type
770 def boundingRect(self, rect=item_bbox):
771 return rect
773 def shape(self):
774 return self.item_shape
776 def paint(self, painter, option, widget,
777 text_opts=text_options,
778 black=QtCore.Qt.black,
779 cache=Cache):
780 # Draw tags
781 painter.setBrush(self.color)
782 painter.setPen(self.pen)
783 painter.drawRoundedRect(self.label_box, 4, 4)
784 try:
785 font = cache.font
786 except AttributeError:
787 font = cache.font = painter.font()
788 font.setPointSize(5)
789 painter.setFont(font)
790 painter.setPen(black)
791 painter.drawText(self.text_box, self.tag_text, text_opts)
794 class GraphView(QtGui.QGraphicsView):
795 def __init__(self, notifier):
796 super(GraphView, self).__init__()
798 try:
799 from PyQt4 import QtOpenGL
800 glformat = QtOpenGL.QGLFormat(QtOpenGL.QGL.SampleBuffers)
801 self.glwidget = QtOpenGL.QGLWidget(glformat)
802 self.setViewport(self.glwidget)
803 except:
804 pass
806 self.x_off = 132
807 self.y_off = 32
808 self.x_max = 0
809 self.y_min = 0
811 self.selected = []
812 self.notifier = notifier
813 self.commits = []
814 self.items = {}
815 self.selected = None
816 self.clicked = None
817 self.saved_matrix = QtGui.QMatrix(self.matrix())
819 self.x_offsets = collections.defaultdict(int)
821 self.is_panning = False
822 self.pressed = False
823 self.selecting = False
824 self.last_mouse = [0, 0]
825 self.zoom = 2
826 self.setDragMode(self.RubberBandDrag)
828 scene = QtGui.QGraphicsScene(self)
829 scene.setItemIndexMethod(QtGui.QGraphicsScene.NoIndex)
830 self.setScene(scene)
833 self.setRenderHint(QtGui.QPainter.Antialiasing)
834 self.setOptimizationFlag(self.DontAdjustForAntialiasing, True)
835 self.setViewportUpdateMode(self.SmartViewportUpdate)
836 self.setCacheMode(QtGui.QGraphicsView.CacheBackground)
837 self.setTransformationAnchor(QtGui.QGraphicsView.AnchorUnderMouse)
838 self.setResizeAnchor(QtGui.QGraphicsView.NoAnchor)
839 self.setBackgroundBrush(QtGui.QColor.fromRgb(0, 0, 0))
841 self.action_zoom_in = (
842 qtutils.add_action(self, 'Zoom In',
843 self.zoom_in,
844 QtCore.Qt.Key_Plus,
845 QtCore.Qt.Key_Equal))
847 self.action_zoom_out = (
848 qtutils.add_action(self, 'Zoom Out',
849 self.zoom_out,
850 QtCore.Qt.Key_Minus))
852 self.action_zoom_fit = (
853 qtutils.add_action(self, 'Zoom to Fit',
854 self.view_fit,
855 QtCore.Qt.Key_F))
857 self.action_select_parent = (
858 qtutils.add_action(self, 'Select Parent',
859 self.select_parent,
860 QtCore.Qt.Key_J))
862 self.action_select_oldest_parent = (
863 qtutils.add_action(self, 'Select Oldest Parent',
864 self.select_oldest_parent,
865 'Shift+J'))
867 self.action_select_child = (
868 qtutils.add_action(self, 'Select Child',
869 self.select_child,
870 QtCore.Qt.Key_K))
872 self.action_select_child = (
873 qtutils.add_action(self, 'Select Nth Child',
874 self.select_nth_child,
875 'Shift+K'))
877 self.menu_actions = context_menu_actions(self)
879 sig = signals.commits_selected
880 notifier.add_observer(sig, self.commits_selected)
882 def clear(self):
883 self.scene().clear()
884 self.selected = []
885 self.items.clear()
886 self.x_offsets.clear()
887 self.x_max = 0
888 self.y_min = 0
889 self.commits = []
891 def zoom_in(self):
892 self.scale_view(1.5)
894 def zoom_out(self):
895 self.scale_view(1.0/1.5)
897 def commits_selected(self, commits):
898 if self.selecting:
899 return
900 self.select([commit.sha1 for commit in commits])
902 def contextMenuEvent(self, event):
903 update_menu_actions(self, event)
904 context_menu_event(self, event)
906 def select(self, sha1s):
907 """Select the item for the SHA-1"""
908 self.scene().clearSelection()
909 for sha1 in sha1s:
910 try:
911 item = self.items[sha1]
912 except KeyError:
913 continue
914 item.blockSignals(True)
915 item.setSelected(True)
916 item.blockSignals(False)
917 item_rect = item.sceneTransform().mapRect(item.boundingRect())
918 self.ensureVisible(item_rect)
920 def selected_item(self):
921 """Return the currently selected item"""
922 selected_items = self.selectedItems()
923 if not selected_items:
924 return None
925 return selected_items[0]
927 def selectedItems(self):
928 """Return the currently selected items"""
929 return self.scene().selectedItems()
931 def get_item_by_generation(self, commits, criteria_fn):
932 """Return the item for the commit matching criteria"""
933 if not commits:
934 return None
935 generation = None
936 for commit in commits:
937 if (generation is None or
938 criteria_fn(generation, commit.generation)):
939 sha1 = commit.sha1
940 generation = commit.generation
941 try:
942 return self.items[sha1]
943 except KeyError:
944 return None
946 def oldest_item(self, commits):
947 """Return the item for the commit with the oldest generation number"""
948 return self.get_item_by_generation(commits, lambda a, b: a > b)
950 def newest_item(self, commits):
951 """Return the item for the commit with the newest generation number"""
952 return self.get_item_by_generation(commits, lambda a, b: a < b)
954 def diff_this_selected(self):
955 clicked_sha1 = self.clicked.commit.sha1
956 selected_sha1 = self.selected.commit.sha1
957 self.emit(SIGNAL('diff_commits'), clicked_sha1, selected_sha1)
959 def diff_selected_this(self):
960 clicked_sha1 = self.clicked.commit.sha1
961 selected_sha1 = self.selected.commit.sha1
962 self.emit(SIGNAL('diff_commits'), selected_sha1, clicked_sha1)
964 def create_patch(self):
965 items = self.selectedItems()
966 if not items:
967 return
968 selected_commits = sort_by_generation([n.commit for n in items])
969 sha1s = [c.sha1 for c in selected_commits]
970 all_sha1s = [c.sha1 for c in self.commits]
971 cola.notifier().broadcast(signals.format_patch, sha1s, all_sha1s)
973 def create_branch(self):
974 sha1 = self.clicked.commit.sha1
975 create_new_branch(revision=sha1)
977 def create_tag(self):
978 sha1 = self.clicked.commit.sha1
979 create_tag(revision=sha1)
981 def cherry_pick(self):
982 sha1 = self.clicked.commit.sha1
983 cola.notifier().broadcast(signals.cherry_pick, [sha1])
984 self.notifier.notify_observers(self.notifier.refs_updated)
986 def select_parent(self):
987 """Select the parent with the newest generation number"""
988 selected_item = self.selected_item()
989 if selected_item is None:
990 return
991 parent_item = self.newest_item(selected_item.commit.parents)
992 if parent_item is None:
993 return
994 selected_item.setSelected(False)
995 parent_item.setSelected(True)
996 self.ensureVisible(parent_item.mapRectToScene(parent_item.boundingRect()))
998 def select_oldest_parent(self):
999 """Select the parent with the oldest generation number"""
1000 selected_item = self.selected_item()
1001 if selected_item is None:
1002 return
1003 parent_item = self.oldest_item(selected_item.commit.parents)
1004 if parent_item is None:
1005 return
1006 selected_item.setSelected(False)
1007 parent_item.setSelected(True)
1008 self.ensureVisible(parent_item.mapRectToScene(parent_item.boundingRect()))
1010 def select_child(self):
1011 """Select the child with the oldest generation number"""
1012 selected_item = self.selected_item()
1013 if selected_item is None:
1014 return
1015 child_item = self.oldest_item(selected_item.commit.children)
1016 if child_item is None:
1017 return
1018 selected_item.setSelected(False)
1019 child_item.setSelected(True)
1020 self.ensureVisible(child_item.mapRectToScene(child_item.boundingRect()))
1022 def select_nth_child(self):
1023 """Select the Nth child with the newest generation number (N > 1)"""
1024 selected_item = self.selected_item()
1025 if selected_item is None:
1026 return
1027 if len(selected_item.commit.children) > 1:
1028 children = selected_item.commit.children[1:]
1029 else:
1030 children = selected_item.commit.children
1031 child_item = self.newest_item(children)
1032 if child_item is None:
1033 return
1034 selected_item.setSelected(False)
1035 child_item.setSelected(True)
1036 self.ensureVisible(child_item.mapRectToScene(child_item.boundingRect()))
1038 def view_fit(self):
1039 """Fit selected items into the viewport"""
1041 items = self.scene().selectedItems()
1042 if not items:
1043 rect = self.scene().itemsBoundingRect()
1044 else:
1045 x_min = sys.maxint
1046 y_min = sys.maxint
1047 x_max = -sys.maxint
1048 ymax = -sys.maxint
1049 for item in items:
1050 pos = item.pos()
1051 item_rect = item.boundingRect()
1052 x_off = item_rect.width()
1053 y_off = item_rect.height()
1054 x_min = min(x_min, pos.x())
1055 y_min = min(y_min, pos.y())
1056 x_max = max(x_max, pos.x()+x_off)
1057 ymax = max(ymax, pos.y()+y_off)
1058 rect = QtCore.QRectF(x_min, y_min, x_max-x_min, ymax-y_min)
1059 adjust = Commit.width * 2
1060 rect.setX(rect.x() - adjust)
1061 rect.setY(rect.y() - adjust)
1062 rect.setHeight(rect.height() + adjust)
1063 rect.setWidth(rect.width() + adjust)
1064 self.fitInView(rect, QtCore.Qt.KeepAspectRatio)
1065 self.scene().invalidate()
1067 def save_selection(self, event):
1068 if event.button() != QtCore.Qt.LeftButton:
1069 return
1070 elif QtCore.Qt.ShiftModifier != event.modifiers():
1071 return
1072 self.selected = self.selectedItems()
1074 def restore_selection(self, event):
1075 if QtCore.Qt.ShiftModifier != event.modifiers():
1076 return
1077 for item in self.selected:
1078 item.setSelected(True)
1080 def handle_event(self, event_handler, event):
1081 self.update()
1082 self.save_selection(event)
1083 event_handler(self, event)
1084 self.restore_selection(event)
1086 def mousePressEvent(self, event):
1087 if event.button() == QtCore.Qt.MidButton:
1088 pos = event.pos()
1089 self.mouse_start = [pos.x(), pos.y()]
1090 self.saved_matrix = QtGui.QMatrix(self.matrix())
1091 self.is_panning = True
1092 return
1093 if event.button() == QtCore.Qt.RightButton:
1094 event.ignore()
1095 return
1096 if event.button() == QtCore.Qt.LeftButton:
1097 self.pressed = True
1098 self.handle_event(QtGui.QGraphicsView.mousePressEvent, event)
1100 def mouseMoveEvent(self, event):
1101 pos = self.mapToScene(event.pos())
1102 if self.is_panning:
1103 self.pan(event)
1104 return
1105 self.last_mouse[0] = pos.x()
1106 self.last_mouse[1] = pos.y()
1107 self.handle_event(QtGui.QGraphicsView.mouseMoveEvent, event)
1109 def set_selecting(self, selecting):
1110 self.selecting = selecting
1112 def mouseReleaseEvent(self, event):
1113 self.pressed = False
1114 if event.button() == QtCore.Qt.MidButton:
1115 self.is_panning = False
1116 return
1117 self.handle_event(QtGui.QGraphicsView.mouseReleaseEvent, event)
1118 self.selected = []
1120 def pan(self, event):
1121 pos = event.pos()
1122 dx = pos.x() - self.mouse_start[0]
1123 dy = pos.y() - self.mouse_start[1]
1125 if dx == 0 and dy == 0:
1126 return
1128 rect = QtCore.QRect(0, 0, abs(dx), abs(dy))
1129 delta = self.mapToScene(rect).boundingRect()
1131 tx = delta.width()
1132 if dx < 0.0:
1133 tx = -tx
1135 ty = delta.height()
1136 if dy < 0.0:
1137 ty = -ty
1139 matrix = QtGui.QMatrix(self.saved_matrix).translate(tx, ty)
1140 self.setTransformationAnchor(QtGui.QGraphicsView.NoAnchor)
1141 self.setMatrix(matrix)
1143 def wheelEvent(self, event):
1144 """Handle Qt mouse wheel events."""
1145 if event.modifiers() == QtCore.Qt.ControlModifier:
1146 self.wheel_zoom(event)
1147 else:
1148 self.wheel_pan(event)
1150 def wheel_zoom(self, event):
1151 """Handle mouse wheel zooming."""
1152 zoom = math.pow(2.0, event.delta() / 512.0)
1153 factor = (self.matrix()
1154 .scale(zoom, zoom)
1155 .mapRect(QtCore.QRectF(0.0, 0.0, 1.0, 1.0))
1156 .width())
1157 if factor < 0.014 or factor > 42.0:
1158 return
1159 self.setTransformationAnchor(QtGui.QGraphicsView.AnchorUnderMouse)
1160 self.zoom = zoom
1161 self.scale(zoom, zoom)
1163 def wheel_pan(self, event):
1164 """Handle mouse wheel panning."""
1166 if event.delta() < 0:
1167 s = -133.
1168 else:
1169 s = 133.
1170 pan_rect = QtCore.QRectF(0.0, 0.0, 1.0, 1.0)
1171 factor = 1.0 / self.matrix().mapRect(pan_rect).width()
1173 if event.orientation() == QtCore.Qt.Vertical:
1174 matrix = self.matrix().translate(0, s * factor)
1175 else:
1176 matrix = self.matrix().translate(s * factor, 0)
1177 self.setTransformationAnchor(QtGui.QGraphicsView.NoAnchor)
1178 self.setMatrix(matrix)
1180 def scale_view(self, scale):
1181 factor = (self.matrix().scale(scale, scale)
1182 .mapRect(QtCore.QRectF(0, 0, 1, 1))
1183 .width())
1184 if factor < 0.07 or factor > 100:
1185 return
1186 self.zoom = scale
1188 adjust_scrollbars = True
1189 scrollbar = self.verticalScrollBar()
1190 if scrollbar:
1191 value = scrollbar.value()
1192 min_ = scrollbar.minimum()
1193 max_ = scrollbar.maximum()
1194 range_ = max_ - min_
1195 distance = value - min_
1196 nonzero_range = float(range_) != 0.0
1197 if nonzero_range:
1198 scrolloffset = distance/float(range_)
1199 else:
1200 adjust_scrollbars = False
1202 self.setTransformationAnchor(QtGui.QGraphicsView.NoAnchor)
1203 self.scale(scale, scale)
1205 scrollbar = self.verticalScrollBar()
1206 if scrollbar and adjust_scrollbars:
1207 min_ = scrollbar.minimum()
1208 max_ = scrollbar.maximum()
1209 range_ = max_ - min_
1210 value = min_ + int(float(range_) * scrolloffset)
1211 scrollbar.setValue(value)
1213 def add_commits(self, commits):
1214 """Traverse commits and add them to the view."""
1215 self.commits.extend(commits)
1216 scene = self.scene()
1217 for commit in commits:
1218 item = Commit(commit, self.notifier)
1219 self.items[commit.sha1] = item
1220 for ref in commit.tags:
1221 self.items[ref] = item
1222 scene.addItem(item)
1224 self.layout_commits(commits)
1225 self.link(commits)
1227 def link(self, commits):
1228 """Create edges linking commits with their parents"""
1229 scene = self.scene()
1230 for commit in commits:
1231 try:
1232 commit_item = self.items[commit.sha1]
1233 except KeyError:
1234 # TODO - Handle truncated history viewing
1235 pass
1236 for parent in commit.parents:
1237 try:
1238 parent_item = self.items[parent.sha1]
1239 except KeyError:
1240 # TODO - Handle truncated history viewing
1241 continue
1242 edge = Edge(parent_item, commit_item)
1243 scene.addItem(edge)
1245 def layout_commits(self, nodes):
1246 positions = self.position_nodes(nodes)
1247 for sha1, (x, y) in positions.items():
1248 item = self.items[sha1]
1249 item.setPos(x, y)
1251 def position_nodes(self, nodes):
1252 positions = {}
1254 x_max = self.x_max
1255 y_min = self.y_min
1256 x_off = self.x_off
1257 y_off = self.y_off
1258 x_offsets = self.x_offsets
1260 for node in nodes:
1261 generation = node.generation
1262 sha1 = node.sha1
1264 if len(node.children) > 1:
1265 # This is a fan-out so sweep over child generations and
1266 # shift them to the right to avoid overlapping edges
1267 child_gens = [c.generation for c in node.children]
1268 maxgen = reduce(max, child_gens)
1269 mingen = reduce(min, child_gens)
1270 if maxgen > mingen:
1271 for g in xrange(generation+1, maxgen):
1272 x_offsets[g] += x_off
1274 if len(node.parents) == 1:
1275 # Align nodes relative to their parents
1276 parent_gen = node.parents[0].generation
1277 parent_off = x_offsets[parent_gen]
1278 x_offsets[generation] = max(parent_off-x_off,
1279 x_offsets[generation])
1281 cur_xoff = x_offsets[generation]
1282 next_xoff = cur_xoff
1283 next_xoff += x_off
1284 x_offsets[generation] = next_xoff
1286 x_pos = cur_xoff
1287 y_pos = -generation * y_off
1288 positions[sha1] = (x_pos, y_pos)
1290 x_max = max(x_max, x_pos)
1291 y_min = min(y_min, y_pos)
1294 self.x_max = x_max
1295 self.y_min = y_min
1297 return positions
1299 def update_scene_rect(self):
1300 y_min = self.y_min
1301 x_max = self.x_max
1302 self.scene().setSceneRect(-self.x_off/2,
1303 y_min-self.y_off,
1304 x_max+self.x_off*2,
1305 abs(y_min)+self.y_off*2)
1307 def sort_by_generation(commits):
1308 commits.sort(cmp=lambda a, b: cmp(a.generation, b.generation))
1309 return commits
1312 def context_menu_actions(self):
1313 return {
1314 'diff_this_selected':
1315 qtutils.add_action(self, 'Diff this -> selected',
1316 self.diff_this_selected),
1317 'diff_selected_this':
1318 qtutils.add_action(self, 'Diff selected -> this',
1319 self.diff_selected_this),
1320 'create_branch':
1321 qtutils.add_action(self, 'Create Branch',
1322 self.create_branch),
1323 'create_patch':
1324 qtutils.add_action(self, 'Create Patch',
1325 self.create_patch),
1326 'create_tag':
1327 qtutils.add_action(self, 'Create Tag',
1328 self.create_tag),
1329 'create_tarball':
1330 qtutils.add_action(self, 'Save As Tarball/Zip...',
1331 lambda: create_tarball(self)),
1332 'cherry_pick':
1333 qtutils.add_action(self, 'Cherry Pick',
1334 self.cherry_pick),
1336 'save_blob':
1337 qtutils.add_action(self, 'Grab File...',
1338 lambda: save_blob_dialog(self)),
1342 def update_menu_actions(self, event):
1343 clicked = self.itemAt(event.pos())
1344 selected_items = self.selectedItems()
1345 has_single_selection = len(selected_items) == 1
1347 has_selection = bool(selected_items)
1348 can_diff = bool(clicked and has_single_selection and
1349 clicked is not selected_items[0])
1351 self.clicked = clicked
1352 if can_diff:
1353 self.selected = selected_items[0]
1354 else:
1355 self.selected = None
1357 self.menu_actions['diff_this_selected'].setEnabled(can_diff)
1358 self.menu_actions['diff_selected_this'].setEnabled(can_diff)
1359 self.menu_actions['create_patch'].setEnabled(has_selection)
1360 self.menu_actions['create_tarball'].setEnabled(has_single_selection)
1361 self.menu_actions['save_blob'].setEnabled(has_single_selection)
1362 self.menu_actions['create_branch'].setEnabled(has_single_selection)
1363 self.menu_actions['create_tag'].setEnabled(has_single_selection)
1364 self.menu_actions['cherry_pick'].setEnabled(has_single_selection)
1367 def context_menu_event(self, event):
1368 menu = QtGui.QMenu(self)
1369 menu.addAction(self.menu_actions['diff_this_selected'])
1370 menu.addAction(self.menu_actions['diff_selected_this'])
1371 menu.addSeparator()
1372 menu.addAction(self.menu_actions['create_branch'])
1373 menu.addAction(self.menu_actions['create_tag'])
1374 menu.addSeparator()
1375 menu.addAction(self.menu_actions['cherry_pick'])
1376 menu.addAction(self.menu_actions['create_patch'])
1377 menu.addAction(self.menu_actions['create_tarball'])
1378 menu.addSeparator()
1379 menu.addAction(self.menu_actions['save_blob'])
1380 menu.exec_(self.mapToGlobal(event.pos()))
1383 def create_tarball(self):
1384 ref = self.clicked.commit.sha1
1385 shortref = ref[:7]
1386 GitArchiveDialog.save(ref, shortref, self)
1389 def save_blob_dialog(self):
1390 return BrowseDialog.browse(self.clicked.commit.sha1)