dag: Speed up drawing performance
[git-cola.git] / cola / dag / view.py
blobb5180a99da6e2b23cc060ca3310d6ee73d0b0d76
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.revtext = GitLogLineEdit(parent=self)
224 self.maxresults = QtGui.QSpinBox()
225 self.maxresults.setMinimum(1)
226 self.maxresults.setMaximum(99999)
227 self.maxresults.setPrefix('git log -')
228 self.maxresults.setSuffix('')
230 self.displaybutton = QtGui.QPushButton()
231 self.displaybutton.setText('Display')
233 self.zoom_in = QtGui.QPushButton()
234 self.zoom_in.setIcon(qtutils.theme_icon('zoom-in.png'))
235 self.zoom_in.setFlat(True)
237 self.zoom_out = QtGui.QPushButton()
238 self.zoom_out.setIcon(qtutils.theme_icon('zoom-out.png'))
239 self.zoom_out.setFlat(True)
241 self.top_layout = QtGui.QHBoxLayout()
242 self.top_layout.setMargin(defs.margin)
243 self.top_layout.setSpacing(defs.button_spacing)
245 self.top_layout.addWidget(self.maxresults)
246 self.top_layout.addWidget(self.revtext)
247 self.top_layout.addWidget(self.displaybutton)
248 self.top_layout.addStretch()
249 self.top_layout.addWidget(self.zoom_out)
250 self.top_layout.addWidget(self.zoom_in)
252 self.commits = {}
253 self.notifier = notifier = observable.Observable()
254 self.notifier.refs_updated = refs_updated = 'refs_updated'
255 self.notifier.add_observer(refs_updated, self.display)
257 self.graphview = GraphView(notifier)
258 self.treewidget = CommitTreeWidget(notifier)
259 self.diffwidget = DiffWidget(notifier)
261 for signal in (archive,):
262 qtutils.relay_signal(self, self.graphview, SIGNAL(signal))
263 qtutils.relay_signal(self, self.treewidget, SIGNAL(signal))
265 self.splitter = QtGui.QSplitter()
266 self.splitter.setOrientation(QtCore.Qt.Horizontal)
267 self.splitter.setChildrenCollapsible(True)
268 self.splitter.setHandleWidth(defs.handle_width)
270 self.left_splitter = QtGui.QSplitter()
271 self.left_splitter.setOrientation(QtCore.Qt.Vertical)
272 self.left_splitter.setChildrenCollapsible(True)
273 self.left_splitter.setHandleWidth(defs.handle_width)
274 self.left_splitter.setStretchFactor(0, 1)
275 self.left_splitter.setStretchFactor(1, 1)
276 self.left_splitter.insertWidget(0, self.treewidget)
277 self.left_splitter.insertWidget(1, self.diffwidget)
279 self.splitter.insertWidget(0, self.left_splitter)
280 self.splitter.insertWidget(1, self.graphview)
282 self.splitter.setStretchFactor(0, 1)
283 self.splitter.setStretchFactor(1, 1)
285 self.main_layout = layout = QtGui.QVBoxLayout()
286 layout.setMargin(0)
287 layout.setSpacing(0)
288 layout.addLayout(self.top_layout)
289 layout.addWidget(self.splitter)
290 self.setLayout(layout)
292 # Also re-loads dag.* from the saved state
293 if not qtutils.apply_state(self):
294 self.resize_to_desktop()
296 # Update fields affected by model
297 self.revtext.setText(dag.ref)
298 self.maxresults.setValue(dag.count)
299 self.update_window_title()
301 self.thread = ReaderThread(self, dag)
303 self.thread.connect(self.thread, self.thread.commits_ready,
304 self.add_commits)
306 self.thread.connect(self.thread, self.thread.done,
307 self.thread_done)
309 self.connect(self.splitter, SIGNAL('splitterMoved(int,int)'),
310 self.splitter_moved)
312 self.connect(self.zoom_in, SIGNAL('pressed()'),
313 self.graphview.zoom_in)
315 self.connect(self.zoom_out, SIGNAL('pressed()'),
316 self.graphview.zoom_out)
318 self.connect(self.treewidget, SIGNAL('diff_commits'),
319 self.diff_commits)
321 self.connect(self.graphview, SIGNAL('diff_commits'),
322 self.diff_commits)
324 self.connect(self.maxresults, SIGNAL('valueChanged(int)'),
325 lambda(x): self.dag.set_count(x))
327 self.connect(self.displaybutton, SIGNAL('pressed()'),
328 self.display)
330 self.connect(self.revtext, SIGNAL('ref_changed'),
331 self.display)
333 self.connect(self.revtext, SIGNAL('textChanged(QString)'),
334 self.text_changed)
336 # The model is updated in another thread so use
337 # signals/slots to bring control back to the main GUI thread
338 self.model.add_observer(self.model.message_updated,
339 self.emit_model_updated)
341 self.connect(self, SIGNAL('model_updated'),
342 self.model_updated)
344 qtutils.add_close_action(self)
346 def text_changed(self, txt):
347 self.dag.ref = unicode(txt)
348 self.update_window_title()
350 def update_window_title(self):
351 project = self.model.project
352 if self.dag.ref:
353 self.setWindowTitle('%s: %s' % (project, self.dag.ref))
354 else:
355 self.setWindowTitle(project)
357 def export_state(self):
358 state = super(DAGView, self).export_state()
359 state['count'] = self.dag.count
360 return state
362 def apply_state(self, state):
363 try:
364 super(DAGView, self).apply_state(state)
365 except:
366 pass
367 try:
368 count = state['count']
369 except KeyError:
370 pass
371 else:
372 if not self.dag.overridden('count'):
373 self.dag.set_count(count)
375 def emit_model_updated(self):
376 self.emit(SIGNAL('model_updated'))
378 def model_updated(self):
379 if self.dag.ref:
380 self.revtext.update_matches()
381 return
382 if not self.model.currentbranch:
383 return
384 self.revtext.setText(self.model.currentbranch)
385 self.display()
387 def display(self):
388 new_ref = unicode(self.revtext.text())
389 if not new_ref:
390 return
391 self.stop()
392 self.clear()
393 self.dag.set_ref(new_ref)
394 self.dag.set_count(self.maxresults.value())
395 self.start()
397 def show(self):
398 super(DAGView, self).show()
399 self.splitter.setSizes([self.width()/2, self.width()/2])
400 self.left_splitter.setSizes([self.height()/3, self.height()*2/3])
401 self.treewidget.adjust_columns()
403 def resizeEvent(self, e):
404 super(DAGView, self).resizeEvent(e)
405 self.treewidget.adjust_columns()
407 def splitter_moved(self, pos, idx):
408 self.treewidget.adjust_columns()
410 def clear(self):
411 self.graphview.clear()
412 self.treewidget.clear()
413 self.commits.clear()
415 def add_commits(self, commits):
416 # Keep track of commits
417 for commit_obj in commits:
418 self.commits[commit_obj.sha1] = commit_obj
419 for tag in commit_obj.tags:
420 self.commits[tag] = commit_obj
421 self.graphview.add_commits(commits)
422 self.treewidget.add_commits(commits)
424 def thread_done(self):
425 try:
426 commit_obj = self.commits[self.dag.ref]
427 except KeyError:
428 return
429 sig = signals.commits_selected
430 self.notifier.notify_observers(sig, [commit_obj])
431 self.graphview.update_scene_rect()
432 self.graphview.view_fit()
434 def closeEvent(self, event):
435 self.revtext.close_popup()
436 self.stop()
437 qtutils.save_state(self)
438 return super(DAGView, self).closeEvent(event)
440 def pause(self):
441 self.thread.mutex.lock()
442 self.thread.stop = True
443 self.thread.mutex.unlock()
445 def stop(self):
446 self.thread.abort = True
447 self.thread.wait()
449 def start(self):
450 self.thread.abort = False
451 self.thread.stop = False
452 self.thread.start()
454 def resume(self):
455 self.thread.mutex.lock()
456 self.thread.stop = False
457 self.thread.mutex.unlock()
458 self.thread.condition.wakeOne()
460 def resize_to_desktop(self):
461 desktop = QtGui.QApplication.instance().desktop()
462 width = desktop.width()
463 height = desktop.height()
464 self.resize(width, height)
466 def diff_commits(self, a, b):
467 paths = self.dag.paths()
468 if paths:
469 difftool.launch([a, b, '--'] + paths)
470 else:
471 difftool.diff_commits(self, a, b)
474 class ReaderThread(QtCore.QThread):
476 commits_ready = SIGNAL('commits_ready')
477 done = SIGNAL('done')
479 def __init__(self, parent, dag):
480 QtCore.QThread.__init__(self, parent)
481 self.dag = dag
482 self.abort = False
483 self.stop = False
484 self.mutex = QtCore.QMutex()
485 self.condition = QtCore.QWaitCondition()
487 def run(self):
488 repo = RepoReader(self.dag)
489 repo.reset()
490 commits = []
491 for c in repo:
492 self.mutex.lock()
493 if self.stop:
494 self.condition.wait(self.mutex)
495 self.mutex.unlock()
496 if self.abort:
497 repo.reset()
498 return
499 commits.append(c)
500 if len(commits) >= 512:
501 self.emit(self.commits_ready, commits)
502 commits = []
504 if commits:
505 self.emit(self.commits_ready, commits)
506 self.emit(self.done)
509 class Cache(object):
510 pass
513 class Edge(QtGui.QGraphicsItem):
514 item_type = QtGui.QGraphicsItem.UserType + 1
515 arrow_size = 2.0
516 arrow_extra = (arrow_size+1.0)/2.0
518 pen = QtGui.QPen(QtCore.Qt.gray, 1.0,
519 QtCore.Qt.DotLine,
520 QtCore.Qt.SquareCap,
521 QtCore.Qt.BevelJoin)
523 def __init__(self, source, dest,
524 extra=arrow_extra,
525 arrow_size=arrow_size):
527 QtGui.QGraphicsItem.__init__(self)
529 self.setAcceptedMouseButtons(QtCore.Qt.NoButton)
530 self.source = source
531 self.dest = dest
532 self.setZValue(-2)
534 dest_pt = Commit.item_bbox.center()
536 self.source_pt = self.mapFromItem(self.source, dest_pt)
537 self.dest_pt = self.mapFromItem(self.dest, dest_pt)
538 self.line = QtCore.QLineF(self.source_pt, self.dest_pt)
540 width = self.dest_pt.x() - self.source_pt.x()
541 height = self.dest_pt.y() - self.source_pt.y()
542 rect = QtCore.QRectF(self.source_pt, QtCore.QSizeF(width, height))
543 self.bound = rect.normalized().adjusted(-extra, -extra, extra, extra)
545 def type(self):
546 return self.item_type
548 def boundingRect(self):
549 return self.bound
551 def paint(self, painter, option, widget,
552 arrow_size=arrow_size,
553 gray=QtCore.Qt.gray):
554 # Draw the line
555 painter.setPen(self.pen)
556 painter.drawLine(self.line)
559 class Commit(QtGui.QGraphicsItem):
560 item_type = QtGui.QGraphicsItem.UserType + 2
561 width = 46.
562 height = 24.
564 item_shape = QtGui.QPainterPath()
565 item_shape.addRect(width/-2., height/-2., width, height)
566 item_bbox = item_shape.boundingRect()
568 inner_rect = QtGui.QPainterPath()
569 inner_rect.addRect(width/-2.+2., height/-2.+2, width-4., height-4.)
570 inner_rect = inner_rect.boundingRect()
572 selected_color = QtGui.QColor.fromRgb(255, 255, 0)
573 outline_color = QtGui.QColor.fromRgb(64, 96, 192)
576 text_options = QtGui.QTextOption()
577 text_options.setAlignment(QtCore.Qt.AlignCenter)
579 commit_pen = QtGui.QPen()
580 commit_pen.setWidth(1.0)
581 commit_pen.setColor(outline_color)
583 cached_commit_color = QtGui.QColor.fromRgb(128, 222, 255)
584 cached_commit_selected_color = QtGui.QColor.fromRgb(32, 64, 255)
585 cached_merge_color = QtGui.QColor.fromRgb(255, 255, 255)
587 def __init__(self, commit,
588 notifier,
589 selectable=QtGui.QGraphicsItem.ItemIsSelectable,
590 cursor=QtCore.Qt.PointingHandCursor,
591 xpos=width/2. + 1.,
592 commit_color=cached_commit_color,
593 commit_selected_color=cached_commit_selected_color,
594 merge_color=cached_merge_color):
596 QtGui.QGraphicsItem.__init__(self)
598 self.setZValue(0)
599 self.setFlag(selectable)
600 self.setCursor(cursor)
602 self.commit = commit
603 self.notifier = notifier
605 if commit.tags:
606 self.label = label = Label(commit)
607 label.setParentItem(self)
608 label.setPos(xpos, 0.)
609 else:
610 self.label = None
612 if len(commit.parents) > 1:
613 self.commit_color = merge_color
614 else:
615 self.commit_color = commit_color
616 self.text_pen = QtCore.Qt.black
617 self.sha1_text = commit.sha1[:8]
619 self.pressed = False
620 self.dragged = False
623 # Overridden Qt methods
626 def blockSignals(self, blocked):
627 self.notifier.notification_enabled = not blocked
629 def itemChange(self, change, value):
630 if change == QtGui.QGraphicsItem.ItemSelectedHasChanged:
631 # Broadcast selection to other widgets
632 selected_items = self.scene().selectedItems()
633 commits = [item.commit for item in selected_items]
634 self.scene().parent().set_selecting(True)
635 sig = signals.commits_selected
636 self.notifier.notify_observers(sig, commits)
637 self.scene().parent().set_selecting(False)
639 # Cache the pen for use in paint()
640 if value.toPyObject():
641 self.commit_color = self.cached_commit_selected_color
642 self.text_pen = QtCore.Qt.white
643 color = self.selected_color
644 else:
645 self.text_pen = QtCore.Qt.black
646 if len(self.commit.parents) > 1:
647 self.commit_color = self.cached_merge_color
648 else:
649 self.commit_color = self.cached_commit_color
650 color = self.outline_color
651 commit_pen = QtGui.QPen()
652 commit_pen.setWidth(1.0)
653 commit_pen.setColor(color)
654 self.commit_pen = commit_pen
656 return QtGui.QGraphicsItem.itemChange(self, change, value)
658 def type(self):
659 return self.item_type
661 def boundingRect(self, rect=item_bbox):
662 return rect
664 def shape(self):
665 return self.item_shape
667 def paint(self, painter, option, widget,
668 inner=inner_rect,
669 text_opts=text_options,
670 cache=Cache):
672 # Do not draw outside the exposed rect
673 painter.setClipRect(option.exposedRect)
675 # Draw ellipse
676 painter.setPen(self.commit_pen)
677 painter.setBrush(self.commit_color)
678 painter.drawEllipse(inner)
680 # Draw text
681 try:
682 font = cache.font
683 except AttributeError:
684 font = cache.font = painter.font()
685 font.setPointSize(5)
686 painter.setFont(font)
687 painter.setPen(self.text_pen)
688 painter.drawText(inner, self.sha1_text, text_opts)
690 def mousePressEvent(self, event):
691 QtGui.QGraphicsItem.mousePressEvent(self, event)
692 self.pressed = True
693 self.selected = self.isSelected()
695 def mouseMoveEvent(self, event):
696 if self.pressed:
697 self.dragged = True
698 QtGui.QGraphicsItem.mouseMoveEvent(self, event)
700 def mouseReleaseEvent(self, event):
701 QtGui.QGraphicsItem.mouseReleaseEvent(self, event)
702 if (not self.dragged and
703 self.selected and
704 event.button() == QtCore.Qt.LeftButton):
705 return
706 self.pressed = False
707 self.dragged = False
710 class Label(QtGui.QGraphicsItem):
711 item_type = QtGui.QGraphicsItem.UserType + 3
713 width = 72
714 height = 18
716 item_shape = QtGui.QPainterPath()
717 item_shape.addRect(0, 0, width, height)
718 item_bbox = item_shape.boundingRect()
720 text_options = QtGui.QTextOption()
721 text_options.setAlignment(QtCore.Qt.AlignCenter)
722 text_options.setAlignment(QtCore.Qt.AlignVCenter)
724 def __init__(self, commit,
725 other_color=QtGui.QColor.fromRgb(255, 255, 64),
726 head_color=QtGui.QColor.fromRgb(64, 255, 64)):
727 QtGui.QGraphicsItem.__init__(self)
728 self.setZValue(-1)
730 # Starts with enough space for two tags. Any more and the commit
731 # needs to be taller to accomodate.
732 self.commit = commit
733 height = len(commit.tags) * self.height/2. + 4. # +6 padding
735 self.label_box = QtCore.QRectF(0., -height/2., self.width, height)
736 self.text_box = QtCore.QRectF(2., -height/2., self.width-4., height)
737 self.tag_text = '\n'.join(commit.tags)
739 if 'HEAD' in commit.tags:
740 self.color = head_color
741 else:
742 self.color = other_color
744 self.pen = QtGui.QPen()
745 self.pen.setColor(self.color.darker())
746 self.pen.setWidth(1.0)
748 def type(self):
749 return self.item_type
751 def boundingRect(self, rect=item_bbox):
752 return rect
754 def shape(self):
755 return self.item_shape
757 def paint(self, painter, option, widget,
758 text_opts=text_options,
759 black=QtCore.Qt.black,
760 cache=Cache):
761 # Draw tags
762 painter.setBrush(self.color)
763 painter.setPen(self.pen)
764 painter.drawRoundedRect(self.label_box, 4, 4)
765 try:
766 font = cache.font
767 except AttributeError:
768 font = cache.font = painter.font()
769 font.setPointSize(5)
770 painter.setFont(font)
771 painter.setPen(black)
772 painter.drawText(self.text_box, self.tag_text, text_opts)
775 class GraphView(QtGui.QGraphicsView):
776 def __init__(self, notifier):
777 QtGui.QGraphicsView.__init__(self)
779 self.x_off = 132
780 self.y_off = 32
781 self.x_max = 0
782 self.y_min = 0
784 self.selected = []
785 self.notifier = notifier
786 self.commits = []
787 self.items = {}
788 self.selected = None
789 self.clicked = None
790 self.saved_matrix = QtGui.QMatrix(self.matrix())
792 self.x_offsets = collections.defaultdict(int)
794 self.is_panning = False
795 self.pressed = False
796 self.selecting = False
797 self.last_mouse = [0, 0]
798 self.zoom = 2
799 self.setDragMode(self.RubberBandDrag)
801 scene = QtGui.QGraphicsScene(self)
802 scene.setItemIndexMethod(QtGui.QGraphicsScene.NoIndex)
803 self.setScene(scene)
806 self.setRenderHint(QtGui.QPainter.Antialiasing)
807 self.setOptimizationFlag(self.DontAdjustForAntialiasing, True)
808 self.setViewportUpdateMode(self.SmartViewportUpdate)
809 self.setCacheMode(QtGui.QGraphicsView.CacheBackground)
810 self.setTransformationAnchor(QtGui.QGraphicsView.AnchorUnderMouse)
811 self.setResizeAnchor(QtGui.QGraphicsView.NoAnchor)
812 self.setBackgroundBrush(QtGui.QColor.fromRgb(0, 0, 0))
814 self.action_zoom_in = (
815 qtutils.add_action(self, 'Zoom In',
816 self.zoom_in,
817 QtCore.Qt.Key_Plus,
818 QtCore.Qt.Key_Equal))
820 self.action_zoom_out = (
821 qtutils.add_action(self, 'Zoom Out',
822 self.zoom_out,
823 QtCore.Qt.Key_Minus))
825 self.action_zoom_fit = (
826 qtutils.add_action(self, 'Zoom to Fit',
827 self.view_fit,
828 QtCore.Qt.Key_F))
830 self.action_select_parent = (
831 qtutils.add_action(self, 'Select Parent',
832 self.select_parent,
833 QtCore.Qt.Key_J))
835 self.action_select_oldest_parent = (
836 qtutils.add_action(self, 'Select Oldest Parent',
837 self.select_oldest_parent,
838 'Shift+J'))
840 self.action_select_child = (
841 qtutils.add_action(self, 'Select Child',
842 self.select_child,
843 QtCore.Qt.Key_K))
845 self.action_select_child = (
846 qtutils.add_action(self, 'Select Nth Child',
847 self.select_nth_child,
848 'Shift+K'))
850 self.menu_actions = context_menu_actions(self)
852 sig = signals.commits_selected
853 notifier.add_observer(sig, self.commits_selected)
855 def clear(self):
856 self.scene().clear()
857 self.selected = []
858 self.items.clear()
859 self.x_offsets.clear()
860 self.x_max = 0
861 self.y_min = 0
862 self.commits = []
864 def zoom_in(self):
865 self.scale_view(1.5)
867 def zoom_out(self):
868 self.scale_view(1.0/1.5)
870 def commits_selected(self, commits):
871 if self.selecting:
872 return
873 self.select([commit.sha1 for commit in commits])
875 def contextMenuEvent(self, event):
876 update_menu_actions(self, event)
877 context_menu_event(self, event)
879 def select(self, sha1s):
880 """Select the item for the SHA-1"""
881 self.scene().clearSelection()
882 for sha1 in sha1s:
883 try:
884 item = self.items[sha1]
885 except KeyError:
886 continue
887 item.blockSignals(True)
888 item.setSelected(True)
889 item.blockSignals(False)
890 item_rect = item.sceneTransform().mapRect(item.boundingRect())
891 self.ensureVisible(item_rect)
893 def selected_item(self):
894 """Return the currently selected item"""
895 selected_items = self.selectedItems()
896 if not selected_items:
897 return None
898 return selected_items[0]
900 def selectedItems(self):
901 """Return the currently selected items"""
902 return self.scene().selectedItems()
904 def get_item_by_generation(self, commits, criteria_fn):
905 """Return the item for the commit matching criteria"""
906 if not commits:
907 return None
908 generation = None
909 for commit in commits:
910 if (generation is None or
911 criteria_fn(generation, commit.generation)):
912 sha1 = commit.sha1
913 generation = commit.generation
914 try:
915 return self.items[sha1]
916 except KeyError:
917 return None
919 def oldest_item(self, commits):
920 """Return the item for the commit with the oldest generation number"""
921 return self.get_item_by_generation(commits, lambda a, b: a > b)
923 def newest_item(self, commits):
924 """Return the item for the commit with the newest generation number"""
925 return self.get_item_by_generation(commits, lambda a, b: a < b)
927 def diff_this_selected(self):
928 clicked_sha1 = self.clicked.commit.sha1
929 selected_sha1 = self.selected.commit.sha1
930 self.emit(SIGNAL('diff_commits'), clicked_sha1, selected_sha1)
932 def diff_selected_this(self):
933 clicked_sha1 = self.clicked.commit.sha1
934 selected_sha1 = self.selected.commit.sha1
935 self.emit(SIGNAL('diff_commits'), selected_sha1, clicked_sha1)
937 def create_patch(self):
938 items = self.selectedItems()
939 if not items:
940 return
941 selected_commits = sort_by_generation([n.commit for n in items])
942 sha1s = [c.sha1 for c in selected_commits]
943 all_sha1s = [c.sha1 for c in self.commits]
944 cola.notifier().broadcast(signals.format_patch, sha1s, all_sha1s)
946 def create_branch(self):
947 sha1 = self.clicked.commit.sha1
948 create_new_branch(revision=sha1)
950 def create_tag(self):
951 sha1 = self.clicked.commit.sha1
952 create_tag(revision=sha1)
954 def cherry_pick(self):
955 sha1 = self.clicked.commit.sha1
956 cola.notifier().broadcast(signals.cherry_pick, [sha1])
957 self.notifier.notify_observers(self.notifier.refs_updated)
959 def select_parent(self):
960 """Select the parent with the newest generation number"""
961 selected_item = self.selected_item()
962 if selected_item is None:
963 return
964 parent_item = self.newest_item(selected_item.commit.parents)
965 if parent_item is None:
966 return
967 selected_item.setSelected(False)
968 parent_item.setSelected(True)
969 self.ensureVisible(parent_item.mapRectToScene(parent_item.boundingRect()))
971 def select_oldest_parent(self):
972 """Select the parent with the oldest generation number"""
973 selected_item = self.selected_item()
974 if selected_item is None:
975 return
976 parent_item = self.oldest_item(selected_item.commit.parents)
977 if parent_item is None:
978 return
979 selected_item.setSelected(False)
980 parent_item.setSelected(True)
981 self.ensureVisible(parent_item.mapRectToScene(parent_item.boundingRect()))
983 def select_child(self):
984 """Select the child with the oldest generation number"""
985 selected_item = self.selected_item()
986 if selected_item is None:
987 return
988 child_item = self.oldest_item(selected_item.commit.children)
989 if child_item is None:
990 return
991 selected_item.setSelected(False)
992 child_item.setSelected(True)
993 self.ensureVisible(child_item.mapRectToScene(child_item.boundingRect()))
995 def select_nth_child(self):
996 """Select the Nth child with the newest generation number (N > 1)"""
997 selected_item = self.selected_item()
998 if selected_item is None:
999 return
1000 if len(selected_item.commit.children) > 1:
1001 children = selected_item.commit.children[1:]
1002 else:
1003 children = selected_item.commit.children
1004 child_item = self.newest_item(children)
1005 if child_item is None:
1006 return
1007 selected_item.setSelected(False)
1008 child_item.setSelected(True)
1009 self.ensureVisible(child_item.mapRectToScene(child_item.boundingRect()))
1011 def view_fit(self):
1012 """Fit selected items into the viewport"""
1014 items = self.scene().selectedItems()
1015 if not items:
1016 rect = self.scene().itemsBoundingRect()
1017 else:
1018 x_min = sys.maxint
1019 y_min = sys.maxint
1020 x_max = -sys.maxint
1021 ymax = -sys.maxint
1022 for item in items:
1023 pos = item.pos()
1024 item_rect = item.boundingRect()
1025 x_off = item_rect.width()
1026 y_off = item_rect.height()
1027 x_min = min(x_min, pos.x())
1028 y_min = min(y_min, pos.y())
1029 x_max = max(x_max, pos.x()+x_off)
1030 ymax = max(ymax, pos.y()+y_off)
1031 rect = QtCore.QRectF(x_min, y_min, x_max-x_min, ymax-y_min)
1032 adjust = Commit.width * 2
1033 rect.setX(rect.x() - adjust)
1034 rect.setY(rect.y() - adjust)
1035 rect.setHeight(rect.height() + adjust)
1036 rect.setWidth(rect.width() + adjust)
1037 self.fitInView(rect, QtCore.Qt.KeepAspectRatio)
1038 self.scene().invalidate()
1040 def save_selection(self, event):
1041 if event.button() != QtCore.Qt.LeftButton:
1042 return
1043 elif QtCore.Qt.ShiftModifier != event.modifiers():
1044 return
1045 self.selected = self.selectedItems()
1047 def restore_selection(self, event):
1048 if QtCore.Qt.ShiftModifier != event.modifiers():
1049 return
1050 for item in self.selected:
1051 item.setSelected(True)
1053 def handle_event(self, event_handler, event):
1054 self.update()
1055 self.save_selection(event)
1056 event_handler(self, event)
1057 self.restore_selection(event)
1059 def mousePressEvent(self, event):
1060 if event.button() == QtCore.Qt.MidButton:
1061 pos = event.pos()
1062 self.mouse_start = [pos.x(), pos.y()]
1063 self.saved_matrix = QtGui.QMatrix(self.matrix())
1064 self.is_panning = True
1065 return
1066 if event.button() == QtCore.Qt.RightButton:
1067 event.ignore()
1068 return
1069 if event.button() == QtCore.Qt.LeftButton:
1070 self.pressed = True
1071 self.handle_event(QtGui.QGraphicsView.mousePressEvent, event)
1073 def mouseMoveEvent(self, event):
1074 pos = self.mapToScene(event.pos())
1075 if self.is_panning:
1076 self.pan(event)
1077 return
1078 self.last_mouse[0] = pos.x()
1079 self.last_mouse[1] = pos.y()
1080 self.handle_event(QtGui.QGraphicsView.mouseMoveEvent, event)
1082 def set_selecting(self, selecting):
1083 self.selecting = selecting
1085 def mouseReleaseEvent(self, event):
1086 self.pressed = False
1087 if event.button() == QtCore.Qt.MidButton:
1088 self.is_panning = False
1089 return
1090 self.handle_event(QtGui.QGraphicsView.mouseReleaseEvent, event)
1091 self.selected = []
1093 def pan(self, event):
1094 pos = event.pos()
1095 dx = pos.x() - self.mouse_start[0]
1096 dy = pos.y() - self.mouse_start[1]
1098 if dx == 0 and dy == 0:
1099 return
1101 rect = QtCore.QRect(0, 0, abs(dx), abs(dy))
1102 delta = self.mapToScene(rect).boundingRect()
1104 tx = delta.width()
1105 if dx < 0.0:
1106 tx = -tx
1108 ty = delta.height()
1109 if dy < 0.0:
1110 ty = -ty
1112 matrix = QtGui.QMatrix(self.saved_matrix).translate(tx, ty)
1113 self.setTransformationAnchor(QtGui.QGraphicsView.NoAnchor)
1114 self.setMatrix(matrix)
1116 def wheelEvent(self, event):
1117 """Handle Qt mouse wheel events."""
1118 if event.modifiers() == QtCore.Qt.ControlModifier:
1119 self.wheel_zoom(event)
1120 else:
1121 self.wheel_pan(event)
1123 def wheel_zoom(self, event):
1124 """Handle mouse wheel zooming."""
1125 zoom = math.pow(2.0, event.delta() / 512.0)
1126 factor = (self.matrix()
1127 .scale(zoom, zoom)
1128 .mapRect(QtCore.QRectF(0.0, 0.0, 1.0, 1.0))
1129 .width())
1130 if factor < 0.014 or factor > 42.0:
1131 return
1132 self.setTransformationAnchor(QtGui.QGraphicsView.AnchorUnderMouse)
1133 self.zoom = zoom
1134 self.scale(zoom, zoom)
1136 def wheel_pan(self, event):
1137 """Handle mouse wheel panning."""
1139 if event.delta() < 0:
1140 s = -133.
1141 else:
1142 s = 133.
1143 pan_rect = QtCore.QRectF(0.0, 0.0, 1.0, 1.0)
1144 factor = 1.0 / self.matrix().mapRect(pan_rect).width()
1146 if event.orientation() == QtCore.Qt.Vertical:
1147 matrix = self.matrix().translate(0, s * factor)
1148 else:
1149 matrix = self.matrix().translate(s * factor, 0)
1150 self.setTransformationAnchor(QtGui.QGraphicsView.NoAnchor)
1151 self.setMatrix(matrix)
1153 def scale_view(self, scale):
1154 factor = (self.matrix().scale(scale, scale)
1155 .mapRect(QtCore.QRectF(0, 0, 1, 1))
1156 .width())
1157 if factor < 0.07 or factor > 100:
1158 return
1159 self.zoom = scale
1161 adjust_scrollbars = True
1162 scrollbar = self.verticalScrollBar()
1163 if scrollbar:
1164 value = scrollbar.value()
1165 min_ = scrollbar.minimum()
1166 max_ = scrollbar.maximum()
1167 range_ = max_ - min_
1168 distance = value - min_
1169 nonzero_range = float(range_) != 0.0
1170 if nonzero_range:
1171 scrolloffset = distance/float(range_)
1172 else:
1173 adjust_scrollbars = False
1175 self.setTransformationAnchor(QtGui.QGraphicsView.NoAnchor)
1176 self.scale(scale, scale)
1178 scrollbar = self.verticalScrollBar()
1179 if scrollbar and adjust_scrollbars:
1180 min_ = scrollbar.minimum()
1181 max_ = scrollbar.maximum()
1182 range_ = max_ - min_
1183 value = min_ + int(float(range_) * scrolloffset)
1184 scrollbar.setValue(value)
1186 def add_commits(self, commits):
1187 """Traverse commits and add them to the view."""
1188 self.commits.extend(commits)
1189 scene = self.scene()
1190 for commit in commits:
1191 item = Commit(commit, self.notifier)
1192 self.items[commit.sha1] = item
1193 for ref in commit.tags:
1194 self.items[ref] = item
1195 scene.addItem(item)
1197 self.layout_commits(commits)
1198 self.link(commits)
1200 def link(self, commits):
1201 """Create edges linking commits with their parents"""
1202 scene = self.scene()
1203 for commit in commits:
1204 try:
1205 commit_item = self.items[commit.sha1]
1206 except KeyError:
1207 # TODO - Handle truncated history viewing
1208 pass
1209 for parent in commit.parents:
1210 try:
1211 parent_item = self.items[parent.sha1]
1212 except KeyError:
1213 # TODO - Handle truncated history viewing
1214 continue
1215 edge = Edge(parent_item, commit_item)
1216 scene.addItem(edge)
1218 def layout_commits(self, nodes):
1219 positions = self.position_nodes(nodes)
1220 for sha1, (x, y) in positions.items():
1221 item = self.items[sha1]
1222 item.setPos(x, y)
1224 def position_nodes(self, nodes):
1225 x_max = self.x_max
1226 y_min = self.y_min
1228 positions = {}
1229 for node in reversed(nodes):
1230 generation = node.generation
1231 sha1 = node.sha1
1233 xoff = self.x_off
1234 cur_xoff = self.x_offsets[generation]
1235 next_xoff = cur_xoff
1236 next_xoff += xoff
1237 self.x_offsets[generation] = next_xoff
1239 if len(node.parents) > 1:
1240 # Sweep across generations from child to farthest
1241 # parents and reserve padding for intermediate
1242 # nodes. This minimizes overlapping edges.
1243 mingen = reduce(min, [p.generation for p in node.parents])
1244 for gen in xrange(mingen+1, node.generation):
1245 new_xoff = self.x_offsets[gen] + xoff
1246 self.x_offsets[gen] = max(new_xoff, next_xoff)
1248 xpos = cur_xoff
1249 ypos = -node.generation * self.y_off
1251 x_max = max(x_max, xpos)
1252 y_min = min(y_min, ypos)
1254 positions[sha1] = (xpos, ypos)
1256 self.x_max = x_max
1257 self.y_min = y_min
1259 return positions
1261 def update_scene_rect(self):
1262 y_min = self.y_min
1263 x_max = self.x_max
1264 self.scene().setSceneRect(-self.x_off/2,
1265 y_min-self.y_off,
1266 x_max+self.x_off*2,
1267 abs(y_min)+self.y_off*2)
1269 def sort_by_generation(commits):
1270 commits.sort(cmp=lambda a, b: cmp(a.generation, b.generation))
1271 return commits
1274 def context_menu_actions(self):
1275 return {
1276 'diff_this_selected':
1277 qtutils.add_action(self, 'Diff this -> selected',
1278 self.diff_this_selected),
1279 'diff_selected_this':
1280 qtutils.add_action(self, 'Diff selected -> this',
1281 self.diff_selected_this),
1282 'create_branch':
1283 qtutils.add_action(self, 'Create Branch',
1284 self.create_branch),
1285 'create_patch':
1286 qtutils.add_action(self, 'Create Patch',
1287 self.create_patch),
1288 'create_tag':
1289 qtutils.add_action(self, 'Create Tag',
1290 self.create_tag),
1291 'create_tarball':
1292 qtutils.add_action(self, 'Save As Tarball/Zip...',
1293 lambda: create_tarball(self)),
1294 'cherry_pick':
1295 qtutils.add_action(self, 'Cherry Pick',
1296 self.cherry_pick),
1298 'save_blob':
1299 qtutils.add_action(self, 'Grab File...',
1300 lambda: save_blob_dialog(self)),
1304 def update_menu_actions(self, event):
1305 clicked = self.itemAt(event.pos())
1306 selected_items = self.selectedItems()
1307 has_single_selection = len(selected_items) == 1
1309 has_selection = bool(selected_items)
1310 can_diff = bool(clicked and has_single_selection and
1311 clicked is not selected_items[0])
1313 self.clicked = clicked
1314 if can_diff:
1315 self.selected = selected_items[0]
1316 else:
1317 self.selected = None
1319 self.menu_actions['diff_this_selected'].setEnabled(can_diff)
1320 self.menu_actions['diff_selected_this'].setEnabled(can_diff)
1321 self.menu_actions['create_patch'].setEnabled(has_selection)
1322 self.menu_actions['create_tarball'].setEnabled(has_single_selection)
1323 self.menu_actions['save_blob'].setEnabled(has_single_selection)
1324 self.menu_actions['create_branch'].setEnabled(has_single_selection)
1325 self.menu_actions['create_tag'].setEnabled(has_single_selection)
1326 self.menu_actions['cherry_pick'].setEnabled(has_single_selection)
1329 def context_menu_event(self, event):
1330 menu = QtGui.QMenu(self)
1331 menu.addAction(self.menu_actions['diff_this_selected'])
1332 menu.addAction(self.menu_actions['diff_selected_this'])
1333 menu.addSeparator()
1334 menu.addAction(self.menu_actions['create_branch'])
1335 menu.addAction(self.menu_actions['create_tag'])
1336 menu.addSeparator()
1337 menu.addAction(self.menu_actions['cherry_pick'])
1338 menu.addAction(self.menu_actions['create_patch'])
1339 menu.addAction(self.menu_actions['create_tarball'])
1340 menu.addSeparator()
1341 menu.addAction(self.menu_actions['save_blob'])
1342 menu.exec_(self.mapToGlobal(event.pos()))
1345 def create_tarball(self):
1346 ref = self.clicked.commit.sha1
1347 shortref = ref[:7]
1348 GitArchiveDialog.save(ref, shortref, self)
1351 def save_blob_dialog(self):
1352 return BrowseDialog.browse(self.clicked.commit.sha1)