dag: Always select the top-most commit
[git-cola.git] / cola / dag / view.py
blob7ec1f2dd85235e675da9e0b034cc37f21313b18c
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 = []
224 self.revtext = GitLogLineEdit(parent=self)
226 self.maxresults = QtGui.QSpinBox()
227 self.maxresults.setMinimum(1)
228 self.maxresults.setMaximum(99999)
229 self.maxresults.setPrefix('git log -')
230 self.maxresults.setSuffix('')
232 self.displaybutton = QtGui.QPushButton()
233 self.displaybutton.setText('Display')
235 self.zoom_in = QtGui.QPushButton()
236 self.zoom_in.setIcon(qtutils.theme_icon('zoom-in.png'))
237 self.zoom_in.setFlat(True)
239 self.zoom_out = QtGui.QPushButton()
240 self.zoom_out.setIcon(qtutils.theme_icon('zoom-out.png'))
241 self.zoom_out.setFlat(True)
243 self.top_layout = QtGui.QHBoxLayout()
244 self.top_layout.setMargin(defs.margin)
245 self.top_layout.setSpacing(defs.button_spacing)
247 self.top_layout.addWidget(self.maxresults)
248 self.top_layout.addWidget(self.revtext)
249 self.top_layout.addWidget(self.displaybutton)
250 self.top_layout.addStretch()
251 self.top_layout.addWidget(self.zoom_out)
252 self.top_layout.addWidget(self.zoom_in)
254 self.notifier = notifier = observable.Observable()
255 self.notifier.refs_updated = refs_updated = 'refs_updated'
256 self.notifier.add_observer(refs_updated, self.display)
258 self.graphview = GraphView(notifier)
259 self.treewidget = CommitTreeWidget(notifier)
260 self.diffwidget = DiffWidget(notifier)
262 for signal in (archive,):
263 qtutils.relay_signal(self, self.graphview, SIGNAL(signal))
264 qtutils.relay_signal(self, self.treewidget, SIGNAL(signal))
266 self.splitter = QtGui.QSplitter()
267 self.splitter.setOrientation(QtCore.Qt.Horizontal)
268 self.splitter.setChildrenCollapsible(True)
269 self.splitter.setHandleWidth(defs.handle_width)
271 self.left_splitter = QtGui.QSplitter()
272 self.left_splitter.setOrientation(QtCore.Qt.Vertical)
273 self.left_splitter.setChildrenCollapsible(True)
274 self.left_splitter.setHandleWidth(defs.handle_width)
275 self.left_splitter.setStretchFactor(0, 1)
276 self.left_splitter.setStretchFactor(1, 1)
277 self.left_splitter.insertWidget(0, self.treewidget)
278 self.left_splitter.insertWidget(1, self.diffwidget)
280 self.splitter.insertWidget(0, self.left_splitter)
281 self.splitter.insertWidget(1, self.graphview)
283 self.splitter.setStretchFactor(0, 1)
284 self.splitter.setStretchFactor(1, 1)
286 self.main_layout = layout = QtGui.QVBoxLayout()
287 layout.setMargin(0)
288 layout.setSpacing(0)
289 layout.addLayout(self.top_layout)
290 layout.addWidget(self.splitter)
291 self.setLayout(layout)
293 # Also re-loads dag.* from the saved state
294 if not qtutils.apply_state(self):
295 self.resize_to_desktop()
297 # Update fields affected by model
298 self.revtext.setText(dag.ref)
299 self.maxresults.setValue(dag.count)
300 self.update_window_title()
302 self.thread = ReaderThread(self, dag)
304 self.thread.connect(self.thread, self.thread.commits_ready,
305 self.add_commits)
307 self.thread.connect(self.thread, self.thread.done,
308 self.thread_done)
310 self.connect(self.splitter, SIGNAL('splitterMoved(int,int)'),
311 self.splitter_moved)
313 self.connect(self.zoom_in, SIGNAL('pressed()'),
314 self.graphview.zoom_in)
316 self.connect(self.zoom_out, SIGNAL('pressed()'),
317 self.graphview.zoom_out)
319 self.connect(self.treewidget, SIGNAL('diff_commits'),
320 self.diff_commits)
322 self.connect(self.graphview, SIGNAL('diff_commits'),
323 self.diff_commits)
325 self.connect(self.maxresults, SIGNAL('valueChanged(int)'),
326 lambda(x): self.dag.set_count(x))
328 self.connect(self.displaybutton, SIGNAL('pressed()'),
329 self.display)
331 self.connect(self.revtext, SIGNAL('ref_changed'),
332 self.display)
334 self.connect(self.revtext, SIGNAL('textChanged(QString)'),
335 self.text_changed)
337 # The model is updated in another thread so use
338 # signals/slots to bring control back to the main GUI thread
339 self.model.add_observer(self.model.message_updated,
340 self.emit_model_updated)
342 self.connect(self, SIGNAL('model_updated'),
343 self.model_updated)
345 qtutils.add_close_action(self)
347 def text_changed(self, txt):
348 self.dag.ref = unicode(txt)
349 self.update_window_title()
351 def update_window_title(self):
352 project = self.model.project
353 if self.dag.ref:
354 self.setWindowTitle('%s: %s' % (project, self.dag.ref))
355 else:
356 self.setWindowTitle(project)
358 def export_state(self):
359 state = super(DAGView, self).export_state()
360 state['count'] = self.dag.count
361 return state
363 def apply_state(self, state):
364 try:
365 super(DAGView, self).apply_state(state)
366 except:
367 pass
368 try:
369 count = state['count']
370 except KeyError:
371 pass
372 else:
373 if not self.dag.overridden('count'):
374 self.dag.set_count(count)
376 def emit_model_updated(self):
377 self.emit(SIGNAL('model_updated'))
379 def model_updated(self):
380 if self.dag.ref:
381 self.revtext.update_matches()
382 return
383 if not self.model.currentbranch:
384 return
385 self.revtext.setText(self.model.currentbranch)
386 self.display()
388 def display(self):
389 new_ref = unicode(self.revtext.text())
390 if not new_ref:
391 return
392 self.stop()
393 self.clear()
394 self.dag.set_ref(new_ref)
395 self.dag.set_count(self.maxresults.value())
396 self.start()
398 def show(self):
399 super(DAGView, self).show()
400 self.splitter.setSizes([self.width()/2, self.width()/2])
401 self.left_splitter.setSizes([self.height()/3, self.height()*2/3])
402 self.treewidget.adjust_columns()
404 def resizeEvent(self, e):
405 super(DAGView, self).resizeEvent(e)
406 self.treewidget.adjust_columns()
408 def splitter_moved(self, pos, idx):
409 self.treewidget.adjust_columns()
411 def clear(self):
412 self.graphview.clear()
413 self.treewidget.clear()
414 self.commits.clear()
415 self.commit_list = []
417 def add_commits(self, commits):
418 self.commit_list.extend(commits)
419 # Keep track of commits
420 for commit_obj in commits:
421 self.commits[commit_obj.sha1] = commit_obj
422 for tag in commit_obj.tags:
423 self.commits[tag] = commit_obj
424 self.graphview.add_commits(commits)
425 self.treewidget.add_commits(commits)
427 def thread_done(self):
428 try:
429 commit_obj = self.commit_list[-1]
430 except IndexError:
431 return
432 sig = signals.commits_selected
433 self.notifier.notify_observers(sig, [commit_obj])
434 self.graphview.update_scene_rect()
435 self.graphview.view_fit()
437 def closeEvent(self, event):
438 self.revtext.close_popup()
439 self.stop()
440 qtutils.save_state(self)
441 return super(DAGView, self).closeEvent(event)
443 def pause(self):
444 self.thread.mutex.lock()
445 self.thread.stop = True
446 self.thread.mutex.unlock()
448 def stop(self):
449 self.thread.abort = True
450 self.thread.wait()
452 def start(self):
453 self.thread.abort = False
454 self.thread.stop = False
455 self.thread.start()
457 def resume(self):
458 self.thread.mutex.lock()
459 self.thread.stop = False
460 self.thread.mutex.unlock()
461 self.thread.condition.wakeOne()
463 def resize_to_desktop(self):
464 desktop = QtGui.QApplication.instance().desktop()
465 width = desktop.width()
466 height = desktop.height()
467 self.resize(width, height)
469 def diff_commits(self, a, b):
470 paths = self.dag.paths()
471 if paths:
472 difftool.launch([a, b, '--'] + paths)
473 else:
474 difftool.diff_commits(self, a, b)
477 class ReaderThread(QtCore.QThread):
479 commits_ready = SIGNAL('commits_ready')
480 done = SIGNAL('done')
482 def __init__(self, parent, dag):
483 QtCore.QThread.__init__(self, parent)
484 self.dag = dag
485 self.abort = False
486 self.stop = False
487 self.mutex = QtCore.QMutex()
488 self.condition = QtCore.QWaitCondition()
490 def run(self):
491 repo = RepoReader(self.dag)
492 repo.reset()
493 commits = []
494 for c in repo:
495 self.mutex.lock()
496 if self.stop:
497 self.condition.wait(self.mutex)
498 self.mutex.unlock()
499 if self.abort:
500 repo.reset()
501 return
502 commits.append(c)
503 if len(commits) >= 512:
504 self.emit(self.commits_ready, commits)
505 commits = []
507 if commits:
508 self.emit(self.commits_ready, commits)
509 self.emit(self.done)
512 class Cache(object):
513 pass
516 class Edge(QtGui.QGraphicsItem):
517 item_type = QtGui.QGraphicsItem.UserType + 1
518 arrow_size = 2.0
519 arrow_extra = (arrow_size+1.0)/2.0
521 pen = QtGui.QPen(QtCore.Qt.gray, 1.3,
522 QtCore.Qt.DotLine,
523 QtCore.Qt.SquareCap,
524 QtCore.Qt.BevelJoin)
526 def __init__(self, source, dest,
527 extra=arrow_extra,
528 arrow_size=arrow_size):
530 QtGui.QGraphicsItem.__init__(self)
532 self.setAcceptedMouseButtons(QtCore.Qt.NoButton)
533 self.source = source
534 self.dest = dest
535 self.setZValue(-2)
537 dest_pt = Commit.item_bbox.center()
539 self.source_pt = self.mapFromItem(self.source, dest_pt)
540 self.dest_pt = self.mapFromItem(self.dest, dest_pt)
541 self.line = QtCore.QLineF(self.source_pt, self.dest_pt)
543 width = self.dest_pt.x() - self.source_pt.x()
544 height = self.dest_pt.y() - self.source_pt.y()
545 rect = QtCore.QRectF(self.source_pt, QtCore.QSizeF(width, height))
546 self.bound = rect.normalized().adjusted(-extra, -extra, extra, extra)
548 def type(self):
549 return self.item_type
551 def boundingRect(self):
552 return self.bound
554 def paint(self, painter, option, widget,
555 arrow_size=arrow_size,
556 gray=QtCore.Qt.gray):
557 # Draw the line
558 painter.setPen(self.pen)
559 painter.drawLine(self.line)
562 class Commit(QtGui.QGraphicsItem):
563 item_type = QtGui.QGraphicsItem.UserType + 2
564 width = 46.
565 height = 24.
567 item_shape = QtGui.QPainterPath()
568 item_shape.addRect(width/-2., height/-2., width, height)
569 item_bbox = item_shape.boundingRect()
571 inner_rect = QtGui.QPainterPath()
572 inner_rect.addRect(width/-2.+2., height/-2.+2, width-4., height-4.)
573 inner_rect = inner_rect.boundingRect()
575 selected_color = QtGui.QColor.fromRgb(255, 255, 0)
576 outline_color = QtGui.QColor.fromRgb(64, 96, 192)
579 text_options = QtGui.QTextOption()
580 text_options.setAlignment(QtCore.Qt.AlignCenter)
582 commit_pen = QtGui.QPen()
583 commit_pen.setWidth(1.0)
584 commit_pen.setColor(outline_color)
586 cached_commit_color = QtGui.QColor.fromRgb(128, 222, 255)
587 cached_commit_selected_color = QtGui.QColor.fromRgb(32, 64, 255)
588 cached_merge_color = QtGui.QColor.fromRgb(255, 255, 255)
590 def __init__(self, commit,
591 notifier,
592 selectable=QtGui.QGraphicsItem.ItemIsSelectable,
593 cursor=QtCore.Qt.PointingHandCursor,
594 xpos=width/2. + 1.,
595 commit_color=cached_commit_color,
596 commit_selected_color=cached_commit_selected_color,
597 merge_color=cached_merge_color):
599 QtGui.QGraphicsItem.__init__(self)
601 self.setZValue(0)
602 self.setFlag(selectable)
603 self.setCursor(cursor)
605 self.commit = commit
606 self.notifier = notifier
608 if commit.tags:
609 self.label = label = Label(commit)
610 label.setParentItem(self)
611 label.setPos(xpos, 0.)
612 else:
613 self.label = None
615 if len(commit.parents) > 1:
616 self.commit_color = merge_color
617 else:
618 self.commit_color = commit_color
619 self.text_pen = QtCore.Qt.black
620 self.sha1_text = commit.sha1[:8]
622 self.pressed = False
623 self.dragged = False
626 # Overridden Qt methods
629 def blockSignals(self, blocked):
630 self.notifier.notification_enabled = not blocked
632 def itemChange(self, change, value):
633 if change == QtGui.QGraphicsItem.ItemSelectedHasChanged:
634 # Broadcast selection to other widgets
635 selected_items = self.scene().selectedItems()
636 commits = [item.commit for item in selected_items]
637 self.scene().parent().set_selecting(True)
638 sig = signals.commits_selected
639 self.notifier.notify_observers(sig, commits)
640 self.scene().parent().set_selecting(False)
642 # Cache the pen for use in paint()
643 if value.toPyObject():
644 self.commit_color = self.cached_commit_selected_color
645 self.text_pen = QtCore.Qt.white
646 color = self.selected_color
647 else:
648 self.text_pen = QtCore.Qt.black
649 if len(self.commit.parents) > 1:
650 self.commit_color = self.cached_merge_color
651 else:
652 self.commit_color = self.cached_commit_color
653 color = self.outline_color
654 commit_pen = QtGui.QPen()
655 commit_pen.setWidth(1.0)
656 commit_pen.setColor(color)
657 self.commit_pen = commit_pen
659 return QtGui.QGraphicsItem.itemChange(self, change, value)
661 def type(self):
662 return self.item_type
664 def boundingRect(self, rect=item_bbox):
665 return rect
667 def shape(self):
668 return self.item_shape
670 def paint(self, painter, option, widget,
671 inner=inner_rect,
672 text_opts=text_options,
673 cache=Cache):
675 # Do not draw outside the exposed rect
676 painter.setClipRect(option.exposedRect)
678 # Draw ellipse
679 painter.setPen(self.commit_pen)
680 painter.setBrush(self.commit_color)
681 painter.drawEllipse(inner)
683 # Draw text
684 try:
685 font = cache.font
686 except AttributeError:
687 font = cache.font = painter.font()
688 font.setPointSize(5)
689 painter.setFont(font)
690 painter.setPen(self.text_pen)
691 painter.drawText(inner, self.sha1_text, text_opts)
693 def mousePressEvent(self, event):
694 QtGui.QGraphicsItem.mousePressEvent(self, event)
695 self.pressed = True
696 self.selected = self.isSelected()
698 def mouseMoveEvent(self, event):
699 if self.pressed:
700 self.dragged = True
701 QtGui.QGraphicsItem.mouseMoveEvent(self, event)
703 def mouseReleaseEvent(self, event):
704 QtGui.QGraphicsItem.mouseReleaseEvent(self, event)
705 if (not self.dragged and
706 self.selected and
707 event.button() == QtCore.Qt.LeftButton):
708 return
709 self.pressed = False
710 self.dragged = False
713 class Label(QtGui.QGraphicsItem):
714 item_type = QtGui.QGraphicsItem.UserType + 3
716 width = 72
717 height = 18
719 item_shape = QtGui.QPainterPath()
720 item_shape.addRect(0, 0, width, height)
721 item_bbox = item_shape.boundingRect()
723 text_options = QtGui.QTextOption()
724 text_options.setAlignment(QtCore.Qt.AlignCenter)
725 text_options.setAlignment(QtCore.Qt.AlignVCenter)
727 def __init__(self, commit,
728 other_color=QtGui.QColor.fromRgb(255, 255, 64),
729 head_color=QtGui.QColor.fromRgb(64, 255, 64)):
730 QtGui.QGraphicsItem.__init__(self)
731 self.setZValue(-1)
733 # Starts with enough space for two tags. Any more and the commit
734 # needs to be taller to accomodate.
735 self.commit = commit
736 height = len(commit.tags) * self.height/2. + 4. # +6 padding
738 self.label_box = QtCore.QRectF(0., -height/2., self.width, height)
739 self.text_box = QtCore.QRectF(2., -height/2., self.width-4., height)
740 self.tag_text = '\n'.join(commit.tags)
742 if 'HEAD' in commit.tags:
743 self.color = head_color
744 else:
745 self.color = other_color
747 self.pen = QtGui.QPen()
748 self.pen.setColor(self.color.darker())
749 self.pen.setWidth(1.0)
751 def type(self):
752 return self.item_type
754 def boundingRect(self, rect=item_bbox):
755 return rect
757 def shape(self):
758 return self.item_shape
760 def paint(self, painter, option, widget,
761 text_opts=text_options,
762 black=QtCore.Qt.black,
763 cache=Cache):
764 # Draw tags
765 painter.setBrush(self.color)
766 painter.setPen(self.pen)
767 painter.drawRoundedRect(self.label_box, 4, 4)
768 try:
769 font = cache.font
770 except AttributeError:
771 font = cache.font = painter.font()
772 font.setPointSize(5)
773 painter.setFont(font)
774 painter.setPen(black)
775 painter.drawText(self.text_box, self.tag_text, text_opts)
778 class GraphView(QtGui.QGraphicsView):
779 def __init__(self, notifier):
780 QtGui.QGraphicsView.__init__(self)
782 self.x_off = 132
783 self.y_off = 32
784 self.x_max = 0
785 self.y_min = 0
787 self.selected = []
788 self.notifier = notifier
789 self.commits = []
790 self.items = {}
791 self.selected = None
792 self.clicked = None
793 self.saved_matrix = QtGui.QMatrix(self.matrix())
795 self.x_offsets = collections.defaultdict(int)
797 self.is_panning = False
798 self.pressed = False
799 self.selecting = False
800 self.last_mouse = [0, 0]
801 self.zoom = 2
802 self.setDragMode(self.RubberBandDrag)
804 scene = QtGui.QGraphicsScene(self)
805 scene.setItemIndexMethod(QtGui.QGraphicsScene.NoIndex)
806 self.setScene(scene)
809 self.setRenderHint(QtGui.QPainter.Antialiasing)
810 self.setOptimizationFlag(self.DontAdjustForAntialiasing, True)
811 self.setViewportUpdateMode(self.SmartViewportUpdate)
812 self.setCacheMode(QtGui.QGraphicsView.CacheBackground)
813 self.setTransformationAnchor(QtGui.QGraphicsView.AnchorUnderMouse)
814 self.setResizeAnchor(QtGui.QGraphicsView.NoAnchor)
815 self.setBackgroundBrush(QtGui.QColor.fromRgb(0, 0, 0))
817 self.action_zoom_in = (
818 qtutils.add_action(self, 'Zoom In',
819 self.zoom_in,
820 QtCore.Qt.Key_Plus,
821 QtCore.Qt.Key_Equal))
823 self.action_zoom_out = (
824 qtutils.add_action(self, 'Zoom Out',
825 self.zoom_out,
826 QtCore.Qt.Key_Minus))
828 self.action_zoom_fit = (
829 qtutils.add_action(self, 'Zoom to Fit',
830 self.view_fit,
831 QtCore.Qt.Key_F))
833 self.action_select_parent = (
834 qtutils.add_action(self, 'Select Parent',
835 self.select_parent,
836 QtCore.Qt.Key_J))
838 self.action_select_oldest_parent = (
839 qtutils.add_action(self, 'Select Oldest Parent',
840 self.select_oldest_parent,
841 'Shift+J'))
843 self.action_select_child = (
844 qtutils.add_action(self, 'Select Child',
845 self.select_child,
846 QtCore.Qt.Key_K))
848 self.action_select_child = (
849 qtutils.add_action(self, 'Select Nth Child',
850 self.select_nth_child,
851 'Shift+K'))
853 self.menu_actions = context_menu_actions(self)
855 sig = signals.commits_selected
856 notifier.add_observer(sig, self.commits_selected)
858 def clear(self):
859 self.scene().clear()
860 self.selected = []
861 self.items.clear()
862 self.x_offsets.clear()
863 self.x_max = 0
864 self.y_min = 0
865 self.commits = []
867 def zoom_in(self):
868 self.scale_view(1.5)
870 def zoom_out(self):
871 self.scale_view(1.0/1.5)
873 def commits_selected(self, commits):
874 if self.selecting:
875 return
876 self.select([commit.sha1 for commit in commits])
878 def contextMenuEvent(self, event):
879 update_menu_actions(self, event)
880 context_menu_event(self, event)
882 def select(self, sha1s):
883 """Select the item for the SHA-1"""
884 self.scene().clearSelection()
885 for sha1 in sha1s:
886 try:
887 item = self.items[sha1]
888 except KeyError:
889 continue
890 item.blockSignals(True)
891 item.setSelected(True)
892 item.blockSignals(False)
893 item_rect = item.sceneTransform().mapRect(item.boundingRect())
894 self.ensureVisible(item_rect)
896 def selected_item(self):
897 """Return the currently selected item"""
898 selected_items = self.selectedItems()
899 if not selected_items:
900 return None
901 return selected_items[0]
903 def selectedItems(self):
904 """Return the currently selected items"""
905 return self.scene().selectedItems()
907 def get_item_by_generation(self, commits, criteria_fn):
908 """Return the item for the commit matching criteria"""
909 if not commits:
910 return None
911 generation = None
912 for commit in commits:
913 if (generation is None or
914 criteria_fn(generation, commit.generation)):
915 sha1 = commit.sha1
916 generation = commit.generation
917 try:
918 return self.items[sha1]
919 except KeyError:
920 return None
922 def oldest_item(self, commits):
923 """Return the item for the commit with the oldest generation number"""
924 return self.get_item_by_generation(commits, lambda a, b: a > b)
926 def newest_item(self, commits):
927 """Return the item for the commit with the newest generation number"""
928 return self.get_item_by_generation(commits, lambda a, b: a < b)
930 def diff_this_selected(self):
931 clicked_sha1 = self.clicked.commit.sha1
932 selected_sha1 = self.selected.commit.sha1
933 self.emit(SIGNAL('diff_commits'), clicked_sha1, selected_sha1)
935 def diff_selected_this(self):
936 clicked_sha1 = self.clicked.commit.sha1
937 selected_sha1 = self.selected.commit.sha1
938 self.emit(SIGNAL('diff_commits'), selected_sha1, clicked_sha1)
940 def create_patch(self):
941 items = self.selectedItems()
942 if not items:
943 return
944 selected_commits = sort_by_generation([n.commit for n in items])
945 sha1s = [c.sha1 for c in selected_commits]
946 all_sha1s = [c.sha1 for c in self.commits]
947 cola.notifier().broadcast(signals.format_patch, sha1s, all_sha1s)
949 def create_branch(self):
950 sha1 = self.clicked.commit.sha1
951 create_new_branch(revision=sha1)
953 def create_tag(self):
954 sha1 = self.clicked.commit.sha1
955 create_tag(revision=sha1)
957 def cherry_pick(self):
958 sha1 = self.clicked.commit.sha1
959 cola.notifier().broadcast(signals.cherry_pick, [sha1])
960 self.notifier.notify_observers(self.notifier.refs_updated)
962 def select_parent(self):
963 """Select the parent with the newest generation number"""
964 selected_item = self.selected_item()
965 if selected_item is None:
966 return
967 parent_item = self.newest_item(selected_item.commit.parents)
968 if parent_item is None:
969 return
970 selected_item.setSelected(False)
971 parent_item.setSelected(True)
972 self.ensureVisible(parent_item.mapRectToScene(parent_item.boundingRect()))
974 def select_oldest_parent(self):
975 """Select the parent with the oldest generation number"""
976 selected_item = self.selected_item()
977 if selected_item is None:
978 return
979 parent_item = self.oldest_item(selected_item.commit.parents)
980 if parent_item is None:
981 return
982 selected_item.setSelected(False)
983 parent_item.setSelected(True)
984 self.ensureVisible(parent_item.mapRectToScene(parent_item.boundingRect()))
986 def select_child(self):
987 """Select the child with the oldest generation number"""
988 selected_item = self.selected_item()
989 if selected_item is None:
990 return
991 child_item = self.oldest_item(selected_item.commit.children)
992 if child_item is None:
993 return
994 selected_item.setSelected(False)
995 child_item.setSelected(True)
996 self.ensureVisible(child_item.mapRectToScene(child_item.boundingRect()))
998 def select_nth_child(self):
999 """Select the Nth child with the newest generation number (N > 1)"""
1000 selected_item = self.selected_item()
1001 if selected_item is None:
1002 return
1003 if len(selected_item.commit.children) > 1:
1004 children = selected_item.commit.children[1:]
1005 else:
1006 children = selected_item.commit.children
1007 child_item = self.newest_item(children)
1008 if child_item is None:
1009 return
1010 selected_item.setSelected(False)
1011 child_item.setSelected(True)
1012 self.ensureVisible(child_item.mapRectToScene(child_item.boundingRect()))
1014 def view_fit(self):
1015 """Fit selected items into the viewport"""
1017 items = self.scene().selectedItems()
1018 if not items:
1019 rect = self.scene().itemsBoundingRect()
1020 else:
1021 x_min = sys.maxint
1022 y_min = sys.maxint
1023 x_max = -sys.maxint
1024 ymax = -sys.maxint
1025 for item in items:
1026 pos = item.pos()
1027 item_rect = item.boundingRect()
1028 x_off = item_rect.width()
1029 y_off = item_rect.height()
1030 x_min = min(x_min, pos.x())
1031 y_min = min(y_min, pos.y())
1032 x_max = max(x_max, pos.x()+x_off)
1033 ymax = max(ymax, pos.y()+y_off)
1034 rect = QtCore.QRectF(x_min, y_min, x_max-x_min, ymax-y_min)
1035 adjust = Commit.width * 2
1036 rect.setX(rect.x() - adjust)
1037 rect.setY(rect.y() - adjust)
1038 rect.setHeight(rect.height() + adjust)
1039 rect.setWidth(rect.width() + adjust)
1040 self.fitInView(rect, QtCore.Qt.KeepAspectRatio)
1041 self.scene().invalidate()
1043 def save_selection(self, event):
1044 if event.button() != QtCore.Qt.LeftButton:
1045 return
1046 elif QtCore.Qt.ShiftModifier != event.modifiers():
1047 return
1048 self.selected = self.selectedItems()
1050 def restore_selection(self, event):
1051 if QtCore.Qt.ShiftModifier != event.modifiers():
1052 return
1053 for item in self.selected:
1054 item.setSelected(True)
1056 def handle_event(self, event_handler, event):
1057 self.update()
1058 self.save_selection(event)
1059 event_handler(self, event)
1060 self.restore_selection(event)
1062 def mousePressEvent(self, event):
1063 if event.button() == QtCore.Qt.MidButton:
1064 pos = event.pos()
1065 self.mouse_start = [pos.x(), pos.y()]
1066 self.saved_matrix = QtGui.QMatrix(self.matrix())
1067 self.is_panning = True
1068 return
1069 if event.button() == QtCore.Qt.RightButton:
1070 event.ignore()
1071 return
1072 if event.button() == QtCore.Qt.LeftButton:
1073 self.pressed = True
1074 self.handle_event(QtGui.QGraphicsView.mousePressEvent, event)
1076 def mouseMoveEvent(self, event):
1077 pos = self.mapToScene(event.pos())
1078 if self.is_panning:
1079 self.pan(event)
1080 return
1081 self.last_mouse[0] = pos.x()
1082 self.last_mouse[1] = pos.y()
1083 self.handle_event(QtGui.QGraphicsView.mouseMoveEvent, event)
1085 def set_selecting(self, selecting):
1086 self.selecting = selecting
1088 def mouseReleaseEvent(self, event):
1089 self.pressed = False
1090 if event.button() == QtCore.Qt.MidButton:
1091 self.is_panning = False
1092 return
1093 self.handle_event(QtGui.QGraphicsView.mouseReleaseEvent, event)
1094 self.selected = []
1096 def pan(self, event):
1097 pos = event.pos()
1098 dx = pos.x() - self.mouse_start[0]
1099 dy = pos.y() - self.mouse_start[1]
1101 if dx == 0 and dy == 0:
1102 return
1104 rect = QtCore.QRect(0, 0, abs(dx), abs(dy))
1105 delta = self.mapToScene(rect).boundingRect()
1107 tx = delta.width()
1108 if dx < 0.0:
1109 tx = -tx
1111 ty = delta.height()
1112 if dy < 0.0:
1113 ty = -ty
1115 matrix = QtGui.QMatrix(self.saved_matrix).translate(tx, ty)
1116 self.setTransformationAnchor(QtGui.QGraphicsView.NoAnchor)
1117 self.setMatrix(matrix)
1119 def wheelEvent(self, event):
1120 """Handle Qt mouse wheel events."""
1121 if event.modifiers() == QtCore.Qt.ControlModifier:
1122 self.wheel_zoom(event)
1123 else:
1124 self.wheel_pan(event)
1126 def wheel_zoom(self, event):
1127 """Handle mouse wheel zooming."""
1128 zoom = math.pow(2.0, event.delta() / 512.0)
1129 factor = (self.matrix()
1130 .scale(zoom, zoom)
1131 .mapRect(QtCore.QRectF(0.0, 0.0, 1.0, 1.0))
1132 .width())
1133 if factor < 0.014 or factor > 42.0:
1134 return
1135 self.setTransformationAnchor(QtGui.QGraphicsView.AnchorUnderMouse)
1136 self.zoom = zoom
1137 self.scale(zoom, zoom)
1139 def wheel_pan(self, event):
1140 """Handle mouse wheel panning."""
1142 if event.delta() < 0:
1143 s = -133.
1144 else:
1145 s = 133.
1146 pan_rect = QtCore.QRectF(0.0, 0.0, 1.0, 1.0)
1147 factor = 1.0 / self.matrix().mapRect(pan_rect).width()
1149 if event.orientation() == QtCore.Qt.Vertical:
1150 matrix = self.matrix().translate(0, s * factor)
1151 else:
1152 matrix = self.matrix().translate(s * factor, 0)
1153 self.setTransformationAnchor(QtGui.QGraphicsView.NoAnchor)
1154 self.setMatrix(matrix)
1156 def scale_view(self, scale):
1157 factor = (self.matrix().scale(scale, scale)
1158 .mapRect(QtCore.QRectF(0, 0, 1, 1))
1159 .width())
1160 if factor < 0.07 or factor > 100:
1161 return
1162 self.zoom = scale
1164 adjust_scrollbars = True
1165 scrollbar = self.verticalScrollBar()
1166 if scrollbar:
1167 value = scrollbar.value()
1168 min_ = scrollbar.minimum()
1169 max_ = scrollbar.maximum()
1170 range_ = max_ - min_
1171 distance = value - min_
1172 nonzero_range = float(range_) != 0.0
1173 if nonzero_range:
1174 scrolloffset = distance/float(range_)
1175 else:
1176 adjust_scrollbars = False
1178 self.setTransformationAnchor(QtGui.QGraphicsView.NoAnchor)
1179 self.scale(scale, scale)
1181 scrollbar = self.verticalScrollBar()
1182 if scrollbar and adjust_scrollbars:
1183 min_ = scrollbar.minimum()
1184 max_ = scrollbar.maximum()
1185 range_ = max_ - min_
1186 value = min_ + int(float(range_) * scrolloffset)
1187 scrollbar.setValue(value)
1189 def add_commits(self, commits):
1190 """Traverse commits and add them to the view."""
1191 self.commits.extend(commits)
1192 scene = self.scene()
1193 for commit in commits:
1194 item = Commit(commit, self.notifier)
1195 self.items[commit.sha1] = item
1196 for ref in commit.tags:
1197 self.items[ref] = item
1198 scene.addItem(item)
1200 self.layout_commits(commits)
1201 self.link(commits)
1203 def link(self, commits):
1204 """Create edges linking commits with their parents"""
1205 scene = self.scene()
1206 for commit in commits:
1207 try:
1208 commit_item = self.items[commit.sha1]
1209 except KeyError:
1210 # TODO - Handle truncated history viewing
1211 pass
1212 for parent in commit.parents:
1213 try:
1214 parent_item = self.items[parent.sha1]
1215 except KeyError:
1216 # TODO - Handle truncated history viewing
1217 continue
1218 edge = Edge(parent_item, commit_item)
1219 scene.addItem(edge)
1221 def layout_commits(self, nodes):
1222 positions = self.position_nodes(nodes)
1223 for sha1, (x, y) in positions.items():
1224 item = self.items[sha1]
1225 item.setPos(x, y)
1227 def position_nodes(self, nodes):
1228 positions = {}
1230 x_max = self.x_max
1231 y_min = self.y_min
1232 x_off = self.x_off
1233 y_off = self.y_off
1234 x_offsets = self.x_offsets
1236 for node in nodes:
1237 generation = node.generation
1238 sha1 = node.sha1
1240 if len(node.children) > 1:
1241 # This is a fan-out so sweep over child generations and
1242 # shift them to the right to avoid overlapping edges
1243 child_gens = [c.generation for c in node.children]
1244 maxgen = reduce(max, child_gens)
1245 mingen = reduce(min, child_gens)
1246 if maxgen > mingen:
1247 for g in xrange(generation+1, maxgen):
1248 x_offsets[g] += x_off
1250 if len(node.parents) == 1:
1251 # Align nodes relative to their parents
1252 parent_gen = node.parents[0].generation
1253 parent_off = x_offsets[parent_gen]
1254 x_offsets[generation] = max(parent_off-x_off,
1255 x_offsets[generation])
1257 cur_xoff = x_offsets[generation]
1258 next_xoff = cur_xoff
1259 next_xoff += x_off
1260 x_offsets[generation] = next_xoff
1262 x_pos = cur_xoff
1263 y_pos = -generation * y_off
1264 positions[sha1] = (x_pos, y_pos)
1266 x_max = max(x_max, x_pos)
1267 y_min = min(y_min, y_pos)
1270 self.x_max = x_max
1271 self.y_min = y_min
1273 return positions
1275 def update_scene_rect(self):
1276 y_min = self.y_min
1277 x_max = self.x_max
1278 self.scene().setSceneRect(-self.x_off/2,
1279 y_min-self.y_off,
1280 x_max+self.x_off*2,
1281 abs(y_min)+self.y_off*2)
1283 def sort_by_generation(commits):
1284 commits.sort(cmp=lambda a, b: cmp(a.generation, b.generation))
1285 return commits
1288 def context_menu_actions(self):
1289 return {
1290 'diff_this_selected':
1291 qtutils.add_action(self, 'Diff this -> selected',
1292 self.diff_this_selected),
1293 'diff_selected_this':
1294 qtutils.add_action(self, 'Diff selected -> this',
1295 self.diff_selected_this),
1296 'create_branch':
1297 qtutils.add_action(self, 'Create Branch',
1298 self.create_branch),
1299 'create_patch':
1300 qtutils.add_action(self, 'Create Patch',
1301 self.create_patch),
1302 'create_tag':
1303 qtutils.add_action(self, 'Create Tag',
1304 self.create_tag),
1305 'create_tarball':
1306 qtutils.add_action(self, 'Save As Tarball/Zip...',
1307 lambda: create_tarball(self)),
1308 'cherry_pick':
1309 qtutils.add_action(self, 'Cherry Pick',
1310 self.cherry_pick),
1312 'save_blob':
1313 qtutils.add_action(self, 'Grab File...',
1314 lambda: save_blob_dialog(self)),
1318 def update_menu_actions(self, event):
1319 clicked = self.itemAt(event.pos())
1320 selected_items = self.selectedItems()
1321 has_single_selection = len(selected_items) == 1
1323 has_selection = bool(selected_items)
1324 can_diff = bool(clicked and has_single_selection and
1325 clicked is not selected_items[0])
1327 self.clicked = clicked
1328 if can_diff:
1329 self.selected = selected_items[0]
1330 else:
1331 self.selected = None
1333 self.menu_actions['diff_this_selected'].setEnabled(can_diff)
1334 self.menu_actions['diff_selected_this'].setEnabled(can_diff)
1335 self.menu_actions['create_patch'].setEnabled(has_selection)
1336 self.menu_actions['create_tarball'].setEnabled(has_single_selection)
1337 self.menu_actions['save_blob'].setEnabled(has_single_selection)
1338 self.menu_actions['create_branch'].setEnabled(has_single_selection)
1339 self.menu_actions['create_tag'].setEnabled(has_single_selection)
1340 self.menu_actions['cherry_pick'].setEnabled(has_single_selection)
1343 def context_menu_event(self, event):
1344 menu = QtGui.QMenu(self)
1345 menu.addAction(self.menu_actions['diff_this_selected'])
1346 menu.addAction(self.menu_actions['diff_selected_this'])
1347 menu.addSeparator()
1348 menu.addAction(self.menu_actions['create_branch'])
1349 menu.addAction(self.menu_actions['create_tag'])
1350 menu.addSeparator()
1351 menu.addAction(self.menu_actions['cherry_pick'])
1352 menu.addAction(self.menu_actions['create_patch'])
1353 menu.addAction(self.menu_actions['create_tarball'])
1354 menu.addSeparator()
1355 menu.addAction(self.menu_actions['save_blob'])
1356 menu.exec_(self.mapToGlobal(event.pos()))
1359 def create_tarball(self):
1360 ref = self.clicked.commit.sha1
1361 shortref = ref[:7]
1362 GitArchiveDialog.save(ref, shortref, self)
1365 def save_blob_dialog(self):
1366 return BrowseDialog.browse(self.clicked.commit.sha1)