dag: Use a QWidget instead of QDialog as the base class
[git-cola.git] / cola / dag / view.py
blobecb39f8e81074ea7af0fe756125b5454be39f639
1 import sys
2 import math
3 from PyQt4 import QtGui
4 from PyQt4 import QtCore
5 from PyQt4.QtCore import SIGNAL
7 import cola
8 from cola import observable
9 from cola import qtutils
10 from cola import signals
11 from cola import gitcmds
12 from cola import difftool
13 from cola.dag.model import archive
14 from cola.dag.model import RepoReader
15 from cola.prefs import diff_font
16 from cola.qt import DiffSyntaxHighlighter
17 from cola.qt import GitLogLineEdit
18 from cola.widgets import defs
19 from cola.widgets import standard
20 from cola.widgets.createbranch import create_new_branch
21 from cola.widgets.createtag import create_tag
22 from cola.widgets.archive import GitArchiveDialog
23 from cola.widgets.browse import BrowseDialog
26 class DiffWidget(QtGui.QWidget):
27 def __init__(self, notifier, parent=None):
28 QtGui.QWidget.__init__(self, parent)
30 self.diff = QtGui.QTextEdit()
31 self.diff.setLineWrapMode(QtGui.QTextEdit.NoWrap)
32 self.diff.setReadOnly(True)
33 self.diff.setFont(diff_font())
34 self.highlighter = DiffSyntaxHighlighter(self.diff.document())
36 self.main_layout = QtGui.QHBoxLayout()
37 self.main_layout.addWidget(self.diff)
38 self.main_layout.setMargin(0)
39 self.main_layout.setSpacing(defs.spacing)
40 self.setLayout(self.main_layout)
42 sig = signals.commits_selected
43 notifier.add_observer(sig, self.commits_selected)
45 def commits_selected(self, commits):
46 if len(commits) != 1:
47 return
48 commit = commits[0]
49 sha1 = commit.sha1
50 merge = len(commit.parents) > 1
51 self.diff.setText(gitcmds.diff_info(sha1, merge=merge))
52 qtutils.set_clipboard(sha1)
55 class CommitTreeWidgetItem(QtGui.QTreeWidgetItem):
56 def __init__(self, commit, parent=None):
57 QtGui.QListWidgetItem.__init__(self, parent)
58 self.commit = commit
59 self.setText(0, commit.summary)
60 self.setText(1, commit.author)
61 self.setText(2, commit.authdate)
64 class CommitTreeWidget(QtGui.QTreeWidget):
65 def __init__(self, notifier, parent=None):
66 QtGui.QTreeWidget.__init__(self, parent)
67 self.setSelectionMode(self.ContiguousSelection)
68 self.setUniformRowHeights(True)
69 self.setAllColumnsShowFocus(True)
70 self.setAlternatingRowColors(True)
71 self.setRootIsDecorated(False)
72 self.setHeaderLabels(['Summary', 'Author', 'Date'])
74 self.sha1map = {}
75 self.notifier = notifier
76 self.selecting = False
77 self.commits = []
78 self.clicked = None
79 self.selected = None
80 self.menu_actions = context_menu_actions(self)
82 self.action_up = qtutils.add_action(self, 'Go Up', self.go_up,
83 QtCore.Qt.Key_K)
85 self.action_down = qtutils.add_action(self, 'Go Down', self.go_down,
86 QtCore.Qt.Key_J)
88 sig = signals.commits_selected
89 notifier.add_observer(sig, self.commits_selected)
91 self.connect(self, SIGNAL('itemSelectionChanged()'),
92 self.selection_changed)
94 def contextMenuEvent(self, event):
95 update_menu_actions(self, event)
96 context_menu_event(self, event)
98 def mousePressEvent(self, event):
99 if event.buttons() == QtCore.Qt.RightButton:
100 event.accept()
101 return
102 QtGui.QTreeWidget.mousePressEvent(self, event)
104 def go_up(self):
105 self.goto(self.itemAbove)
107 def go_down(self):
108 self.goto(self.itemBelow)
110 def goto(self, finder):
111 items = self.selectedItems()
112 item = items and items[0] or None
113 if item is None:
114 return
115 found = finder(item)
116 if found:
117 self.select([found.commit.sha1], block_signals=False)
119 def set_selecting(self, selecting):
120 self.selecting = selecting
122 def selection_changed(self):
123 items = self.selectedItems()
124 if not items:
125 return
126 self.set_selecting(True)
127 sig = signals.commits_selected
128 self.notifier.notify_observers(sig, [i.commit for i in items])
129 self.set_selecting(False)
131 def commits_selected(self, commits):
132 if self.selecting:
133 return
134 self.select([commit.sha1 for commit in commits])
136 def select(self, sha1s, block_signals=True):
137 self.clearSelection()
138 for sha1 in sha1s:
139 try:
140 item = self.sha1map[sha1]
141 except KeyError:
142 continue
143 block = self.blockSignals(block_signals)
144 self.scrollToItem(item)
145 item.setSelected(True)
146 self.blockSignals(block)
148 def adjust_columns(self):
149 width = self.width()-20
150 zero = width*2/3
151 onetwo = width/6
152 self.setColumnWidth(0, zero)
153 self.setColumnWidth(1, onetwo)
154 self.setColumnWidth(2, onetwo)
156 def clear(self):
157 QtGui.QTreeWidget.clear(self)
158 self.sha1map.clear()
159 self.commits = []
161 def add_commits(self, commits):
162 self.commits.extend(commits)
163 items = []
164 for c in reversed(commits):
165 item = CommitTreeWidgetItem(c)
166 items.append(item)
167 self.sha1map[c.sha1] = item
168 for tag in c.tags:
169 self.sha1map[tag] = item
170 self.insertTopLevelItems(0, items)
172 def diff_this_selected(self):
173 clicked_sha1 = self.clicked.commit.sha1
174 selected_sha1 = self.selected.commit.sha1
175 self.emit(SIGNAL('diff_commits'), clicked_sha1, selected_sha1)
177 def diff_selected_this(self):
178 clicked_sha1 = self.clicked.commit.sha1
179 selected_sha1 = self.selected.commit.sha1
180 self.emit(SIGNAL('diff_commits'), selected_sha1, clicked_sha1)
182 def create_patch(self):
183 items = self.selectedItems()
184 if not items:
185 return
186 items.reverse()
187 sha1s = [item.commit.sha1 for item in items]
188 all_sha1s = [c.sha1 for c in self.commits]
189 cola.notifier().broadcast(signals.format_patch, sha1s, all_sha1s)
191 def create_branch(self):
192 sha1 = self.clicked.commit.sha1
193 create_new_branch(revision=sha1)
195 def create_tag(self):
196 sha1 = self.clicked.commit.sha1
197 create_tag(revision=sha1)
199 def cherry_pick(self):
200 sha1 = self.clicked.commit.sha1
201 cola.notifier().broadcast(signals.cherry_pick, [sha1])
204 class DAGView(standard.Widget):
205 """The git-dag widget."""
207 def __init__(self, model, dag, parent=None, args=None):
208 super(DAGView, self).__init__(parent)
209 self.setAttribute(QtCore.Qt.WA_MacMetalStyle)
210 self.setMinimumSize(1, 1)
212 # change when widgets are added/removed
213 self.widget_version = 1
214 self.model = model
215 self.dag = dag
217 self.revtext = GitLogLineEdit(parent=self)
219 self.maxresults = QtGui.QSpinBox()
220 self.maxresults.setMinimum(1)
221 self.maxresults.setMaximum(99999)
222 self.maxresults.setPrefix('git log -')
223 self.maxresults.setSuffix('')
225 self.displaybutton = QtGui.QPushButton()
226 self.displaybutton.setText('Display')
228 self.zoom_in = QtGui.QPushButton()
229 self.zoom_in.setIcon(qtutils.theme_icon('zoom-in.png'))
230 self.zoom_in.setFlat(True)
232 self.zoom_out = QtGui.QPushButton()
233 self.zoom_out.setIcon(qtutils.theme_icon('zoom-out.png'))
234 self.zoom_out.setFlat(True)
236 self.top_layout = QtGui.QHBoxLayout()
237 self.top_layout.setMargin(defs.margin)
238 self.top_layout.setSpacing(defs.button_spacing)
240 self.top_layout.addWidget(self.maxresults)
241 self.top_layout.addWidget(self.revtext)
242 self.top_layout.addWidget(self.displaybutton)
243 self.top_layout.addStretch()
244 self.top_layout.addWidget(self.zoom_out)
245 self.top_layout.addWidget(self.zoom_in)
247 self.commits = {}
248 self.notifier = notifier = observable.Observable()
249 self.notifier.refs_updated = refs_updated = 'refs_updated'
250 self.notifier.add_observer(refs_updated, self.display)
252 self.graphview = GraphView(notifier)
253 self.treewidget = CommitTreeWidget(notifier)
254 self.diffwidget = DiffWidget(notifier)
256 for signal in (archive,):
257 qtutils.relay_signal(self, self.graphview, SIGNAL(signal))
258 qtutils.relay_signal(self, self.treewidget, SIGNAL(signal))
260 self.splitter = QtGui.QSplitter()
261 self.splitter.setOrientation(QtCore.Qt.Horizontal)
262 self.splitter.setChildrenCollapsible(True)
263 self.splitter.setHandleWidth(defs.handle_width)
265 self.left_splitter = QtGui.QSplitter()
266 self.left_splitter.setOrientation(QtCore.Qt.Vertical)
267 self.left_splitter.setChildrenCollapsible(True)
268 self.left_splitter.setHandleWidth(defs.handle_width)
269 self.left_splitter.setStretchFactor(0, 1)
270 self.left_splitter.setStretchFactor(1, 1)
271 self.left_splitter.insertWidget(0, self.treewidget)
272 self.left_splitter.insertWidget(1, self.diffwidget)
274 self.splitter.insertWidget(0, self.left_splitter)
275 self.splitter.insertWidget(1, self.graphview)
277 self.splitter.setStretchFactor(0, 1)
278 self.splitter.setStretchFactor(1, 1)
280 self.main_layout = layout = QtGui.QVBoxLayout()
281 layout.setMargin(0)
282 layout.setSpacing(0)
283 layout.addLayout(self.top_layout)
284 layout.addWidget(self.splitter)
285 self.setLayout(layout)
287 # Also re-loads dag.* from the saved state
288 if not qtutils.apply_state(self):
289 self.resize_to_desktop()
291 # Update fields affected by model
292 self.revtext.setText(dag.ref)
293 self.maxresults.setValue(dag.count)
294 self.update_window_title()
296 self.thread = ReaderThread(self, dag)
298 self.thread.connect(self.thread, self.thread.commits_ready,
299 self.add_commits)
301 self.thread.connect(self.thread, self.thread.done,
302 self.thread_done)
304 self.connect(self.splitter, SIGNAL('splitterMoved(int,int)'),
305 self.splitter_moved)
307 self.connect(self.zoom_in, SIGNAL('pressed()'),
308 self.graphview.zoom_in)
310 self.connect(self.zoom_out, SIGNAL('pressed()'),
311 self.graphview.zoom_out)
313 self.connect(self.treewidget, SIGNAL('diff_commits'),
314 self.diff_commits)
316 self.connect(self.graphview, SIGNAL('diff_commits'),
317 self.diff_commits)
319 self.connect(self.maxresults, SIGNAL('valueChanged(int)'),
320 lambda(x): self.dag.set_count(x))
322 self.connect(self.displaybutton, SIGNAL('pressed()'),
323 self.display)
325 self.connect(self.revtext, SIGNAL('ref_changed'),
326 self.display)
328 self.connect(self.revtext, SIGNAL('textChanged(QString)'),
329 self.text_changed)
331 # The model is updated in another thread so use
332 # signals/slots to bring control back to the main GUI thread
333 self.model.add_observer(self.model.message_updated,
334 self.emit_model_updated)
336 self.connect(self, SIGNAL('model_updated'),
337 self.model_updated)
339 qtutils.add_close_action(self)
341 def text_changed(self, txt):
342 self.dag.ref = unicode(txt)
343 self.update_window_title()
345 def update_window_title(self):
346 project = self.model.project
347 if self.dag.ref:
348 self.setWindowTitle('%s: %s' % (project, self.dag.ref))
349 else:
350 self.setWindowTitle(project)
352 def export_state(self):
353 state = super(DAGView, self).export_state()
354 state['count'] = self.dag.count
355 return state
357 def apply_state(self, state):
358 try:
359 super(DAGView, self).apply_state(state)
360 except:
361 pass
362 try:
363 count = state['count']
364 except KeyError:
365 pass
366 else:
367 if not self.dag.overridden('count'):
368 self.dag.set_count(count)
370 def emit_model_updated(self):
371 self.emit(SIGNAL('model_updated'))
373 def model_updated(self):
374 if self.dag.ref:
375 self.revtext.update_matches()
376 return
377 if not self.model.currentbranch:
378 return
379 self.revtext.setText(self.model.currentbranch)
380 self.display()
382 def display(self):
383 new_ref = unicode(self.revtext.text())
384 if not new_ref:
385 return
386 self.stop()
387 self.clear()
388 self.dag.set_ref(new_ref)
389 self.dag.set_count(self.maxresults.value())
390 self.start()
392 def show(self):
393 super(DAGView, self).show()
394 self.splitter.setSizes([self.width()/2, self.width()/2])
395 self.left_splitter.setSizes([self.height()/3, self.height()*2/3])
396 self.treewidget.adjust_columns()
398 def resizeEvent(self, e):
399 super(DAGView, self).resizeEvent(e)
400 self.treewidget.adjust_columns()
402 def splitter_moved(self, pos, idx):
403 self.treewidget.adjust_columns()
405 def clear(self):
406 self.graphview.clear()
407 self.treewidget.clear()
408 self.commits.clear()
410 def add_commits(self, commits):
411 # Keep track of commits
412 for commit_obj in commits:
413 self.commits[commit_obj.sha1] = commit_obj
414 for tag in commit_obj.tags:
415 self.commits[tag] = commit_obj
416 self.graphview.add_commits(commits)
417 self.treewidget.add_commits(commits)
419 def thread_done(self):
420 try:
421 commit_obj = self.commits[self.dag.ref]
422 except KeyError:
423 return
424 sig = signals.commits_selected
425 self.notifier.notify_observers(sig, [commit_obj])
426 self.graphview.view_fit()
428 def closeEvent(self, event):
429 self.stop()
430 self.revtext.close_popup()
431 qtutils.save_state(self)
432 return super(DAGView, self).closeEvent(event)
434 def pause(self):
435 self.thread.mutex.lock()
436 self.thread.stop = True
437 self.thread.mutex.unlock()
439 def stop(self):
440 self.thread.abort = True
441 self.thread.wait()
443 def start(self):
444 self.thread.abort = False
445 self.thread.stop = False
446 self.thread.start()
448 def resume(self):
449 self.thread.mutex.lock()
450 self.thread.stop = False
451 self.thread.mutex.unlock()
452 self.thread.condition.wakeOne()
454 def resize_to_desktop(self):
455 desktop = QtGui.QApplication.instance().desktop()
456 width = desktop.width()
457 height = desktop.height()
458 self.resize(width, height)
460 def diff_commits(self, a, b):
461 paths = self.dag.paths()
462 if paths:
463 difftool.launch([a, b, '--'] + paths)
464 else:
465 difftool.diff_commits(self, a, b)
468 class ReaderThread(QtCore.QThread):
470 commits_ready = SIGNAL('commits_ready')
471 done = SIGNAL('done')
473 def __init__(self, parent, dag):
474 QtCore.QThread.__init__(self, parent)
475 self.dag = dag
476 self.abort = False
477 self.stop = False
478 self.mutex = QtCore.QMutex()
479 self.condition = QtCore.QWaitCondition()
481 def run(self):
482 repo = RepoReader(self.dag)
483 repo.reset()
484 commits = []
485 for c in repo:
486 self.mutex.lock()
487 if self.stop:
488 self.condition.wait(self.mutex)
489 self.mutex.unlock()
490 if self.abort:
491 repo.reset()
492 return
493 commits.append(c)
494 if len(commits) >= 512:
495 self.emit(self.commits_ready, commits)
496 commits = []
498 if commits:
499 self.emit(self.commits_ready, commits)
500 self.emit(self.done)
503 class Cache(object):
504 pass
507 class Edge(QtGui.QGraphicsItem):
508 item_type = QtGui.QGraphicsItem.UserType + 1
509 arrow_size = 2.0
510 arrow_extra = (arrow_size+1.0)/2.0
512 def __init__(self, source, dest,
513 extra=arrow_extra,
514 arrow_size=arrow_size):
515 QtGui.QGraphicsItem.__init__(self)
517 self.source_pt = QtCore.QPointF()
518 self.dest_pt = QtCore.QPointF()
519 self.setAcceptedMouseButtons(QtCore.Qt.NoButton)
520 self.source = source
521 self.dest = dest
522 self.setZValue(-2)
524 # Adjust the points to leave a small margin between
525 # the arrow and the commit.
526 dest_pt = Commit.item_bbox.center()
527 line = QtCore.QLineF(
528 self.mapFromItem(self.source, dest_pt),
529 self.mapFromItem(self.dest, dest_pt))
530 # Magic
531 dx = 22.
532 dy = 11.
533 length = line.length()
534 offset = QtCore.QPointF((line.dx() * dx) / length,
535 (line.dy() * dy) / length)
537 self.source_pt = line.p1() + offset
538 self.dest_pt = line.p2() - offset
540 line = QtCore.QLineF(self.source_pt, self.dest_pt)
541 self.line = line
543 self.pen = QtGui.QPen(QtCore.Qt.gray, 0,
544 QtCore.Qt.DotLine,
545 QtCore.Qt.FlatCap,
546 QtCore.Qt.MiterJoin)
548 # Setup the arrow polygon
549 length = line.length()
550 angle = math.acos(line.dx() / length)
551 if line.dy() >= 0:
552 angle = 2.0 * math.pi - angle
554 dest_x = (self.dest_pt +
555 QtCore.QPointF(math.sin(angle - math.pi/3.) *
556 arrow_size,
557 math.cos(angle - math.pi/3.) *
558 arrow_size))
559 dest_y = (self.dest_pt +
560 QtCore.QPointF(math.sin(angle - math.pi + math.pi/3.) *
561 arrow_size,
562 math.cos(angle - math.pi + math.pi/3.) *
563 arrow_size))
564 self.poly = QtGui.QPolygonF([line.p2(), dest_x, dest_y])
566 width = self.dest_pt.x() - self.source_pt.x()
567 height = self.dest_pt.y() - self.source_pt.y()
568 rect = QtCore.QRectF(self.source_pt, QtCore.QSizeF(width, height))
569 self.bound = rect.normalized().adjusted(-extra, -extra, extra, extra)
571 def type(self):
572 return self.item_type
574 def boundingRect(self):
575 return self.bound
577 def paint(self, painter, option, widget,
578 arrow_size=arrow_size,
579 gray=QtCore.Qt.gray):
580 # Draw the line
581 painter.setPen(self.pen)
582 painter.drawLine(self.line)
584 # Draw the arrow
585 painter.setBrush(gray)
586 painter.drawPolygon(self.poly)
589 class Commit(QtGui.QGraphicsItem):
590 item_type = QtGui.QGraphicsItem.UserType + 2
591 width = 46.
592 height = 24.
594 item_shape = QtGui.QPainterPath()
595 item_shape.addRect(width/-2., height/-2., width, height)
596 item_bbox = item_shape.boundingRect()
598 inner_rect = QtGui.QPainterPath()
599 inner_rect.addRect(width/-2.+2., height/-2.+2, width-4., height-4.)
600 inner_rect = inner_rect.boundingRect()
602 selected_color = QtGui.QColor.fromRgb(255, 255, 0)
603 outline_color = QtGui.QColor.fromRgb(64, 96, 192)
606 text_options = QtGui.QTextOption()
607 text_options.setAlignment(QtCore.Qt.AlignCenter)
609 commit_pen = QtGui.QPen()
610 commit_pen.setWidth(1.0)
611 commit_pen.setColor(outline_color)
613 cached_commit_color = QtGui.QColor.fromRgb(128, 222, 255)
614 cached_commit_selected_color = QtGui.QColor.fromRgb(32, 64, 255)
615 cached_merge_color = QtGui.QColor.fromRgb(255, 255, 255)
617 def __init__(self, commit,
618 notifier,
619 selectable=QtGui.QGraphicsItem.ItemIsSelectable,
620 cursor=QtCore.Qt.PointingHandCursor,
621 xpos=width/2. + 1.,
622 commit_color=cached_commit_color,
623 commit_selected_color=cached_commit_selected_color,
624 merge_color=cached_merge_color):
626 QtGui.QGraphicsItem.__init__(self)
628 self.setZValue(0)
629 self.setFlag(selectable)
630 self.setCursor(cursor)
632 self.commit = commit
633 self.notifier = notifier
635 if commit.tags:
636 self.label = label = Label(commit)
637 label.setParentItem(self)
638 label.setPos(xpos, 0.)
639 else:
640 self.label = None
642 if len(commit.parents) > 1:
643 self.commit_color = merge_color
644 else:
645 self.commit_color = commit_color
646 self.text_pen = QtCore.Qt.black
647 self.sha1_text = commit.sha1[:8]
649 self.pressed = False
650 self.dragged = False
653 # Overridden Qt methods
656 def blockSignals(self, blocked):
657 self.notifier.notification_enabled = not blocked
659 def itemChange(self, change, value):
660 if change == QtGui.QGraphicsItem.ItemSelectedHasChanged:
661 # Broadcast selection to other widgets
662 selected_items = self.scene().selectedItems()
663 commits = [item.commit for item in selected_items]
664 self.scene().parent().set_selecting(True)
665 sig = signals.commits_selected
666 self.notifier.notify_observers(sig, commits)
667 self.scene().parent().set_selecting(False)
669 # Cache the pen for use in paint()
670 if value.toPyObject():
671 self.commit_color = self.cached_commit_selected_color
672 self.text_pen = QtCore.Qt.white
673 color = self.selected_color
674 else:
675 self.text_pen = QtCore.Qt.black
676 if len(self.commit.parents) > 1:
677 self.commit_color = self.cached_merge_color
678 else:
679 self.commit_color = self.cached_commit_color
680 color = self.outline_color
681 commit_pen = QtGui.QPen()
682 commit_pen.setWidth(1.0)
683 commit_pen.setColor(color)
684 self.commit_pen = commit_pen
686 return QtGui.QGraphicsItem.itemChange(self, change, value)
688 def type(self):
689 return self.item_type
691 def boundingRect(self, rect=item_bbox):
692 return rect
694 def shape(self):
695 return self.item_shape
697 def paint(self, painter, option, widget,
698 inner=inner_rect,
699 text_opts=text_options,
700 cache=Cache):
702 # Do not draw outside the exposed rect
703 painter.setClipRect(option.exposedRect)
705 # Draw ellipse
706 painter.setPen(self.commit_pen)
707 painter.setBrush(self.commit_color)
708 painter.drawEllipse(inner)
710 # Draw text
711 try:
712 font = cache.font
713 except AttributeError:
714 font = cache.font = painter.font()
715 font.setPointSize(5)
716 painter.setFont(font)
717 painter.setPen(self.text_pen)
718 painter.drawText(inner, self.sha1_text, text_opts)
720 def mousePressEvent(self, event):
721 QtGui.QGraphicsItem.mousePressEvent(self, event)
722 self.pressed = True
723 self.selected = self.isSelected()
725 def mouseMoveEvent(self, event):
726 if self.pressed:
727 self.dragged = True
728 QtGui.QGraphicsItem.mouseMoveEvent(self, event)
730 def mouseReleaseEvent(self, event):
731 QtGui.QGraphicsItem.mouseReleaseEvent(self, event)
732 if (not self.dragged and
733 self.selected and
734 event.button() == QtCore.Qt.LeftButton):
735 return
736 self.pressed = False
737 self.dragged = False
740 class Label(QtGui.QGraphicsItem):
741 item_type = QtGui.QGraphicsItem.UserType + 3
743 width = 72
744 height = 18
746 item_shape = QtGui.QPainterPath()
747 item_shape.addRect(0, 0, width, height)
748 item_bbox = item_shape.boundingRect()
750 text_options = QtGui.QTextOption()
751 text_options.setAlignment(QtCore.Qt.AlignCenter)
752 text_options.setAlignment(QtCore.Qt.AlignVCenter)
754 def __init__(self, commit,
755 other_color=QtGui.QColor.fromRgb(255, 255, 64),
756 head_color=QtGui.QColor.fromRgb(64, 255, 64)):
757 QtGui.QGraphicsItem.__init__(self)
758 self.setZValue(-1)
760 # Starts with enough space for two tags. Any more and the commit
761 # needs to be taller to accomodate.
762 self.commit = commit
763 height = len(commit.tags) * self.height/2. + 4. # +6 padding
765 self.label_box = QtCore.QRectF(0., -height/2., self.width, height)
766 self.text_box = QtCore.QRectF(2., -height/2., self.width-4., height)
767 self.tag_text = '\n'.join(commit.tags)
769 if 'HEAD' in commit.tags:
770 self.color = head_color
771 else:
772 self.color = other_color
774 self.pen = QtGui.QPen()
775 self.pen.setColor(self.color.darker())
776 self.pen.setWidth(1.0)
778 def type(self):
779 return self.item_type
781 def boundingRect(self, rect=item_bbox):
782 return rect
784 def shape(self):
785 return self.item_shape
787 def paint(self, painter, option, widget,
788 text_opts=text_options,
789 black=QtCore.Qt.black,
790 cache=Cache):
791 # Draw tags
792 painter.setBrush(self.color)
793 painter.setPen(self.pen)
794 painter.drawRoundedRect(self.label_box, 4, 4)
795 try:
796 font = cache.font
797 except AttributeError:
798 font = cache.font = painter.font()
799 font.setPointSize(5)
800 painter.setFont(font)
801 painter.setPen(black)
802 painter.drawText(self.text_box, self.tag_text, text_opts)
805 class GraphView(QtGui.QGraphicsView):
806 def __init__(self, notifier):
807 QtGui.QGraphicsView.__init__(self)
809 self.x_off = 132
810 self.y_off = 32
811 self.x_max = 0
812 self.y_min = 0
814 self.selected = []
815 self.notifier = notifier
816 self.commits = []
817 self.items = {}
818 self.selected = None
819 self.clicked = None
820 self.saved_matrix = QtGui.QMatrix(self.matrix())
822 self.rows = {}
824 self.is_panning = False
825 self.pressed = False
826 self.selecting = False
827 self.last_mouse = [0, 0]
828 self.zoom = 2
829 self.setDragMode(self.RubberBandDrag)
831 scene = QtGui.QGraphicsScene(self)
832 scene.setItemIndexMethod(QtGui.QGraphicsScene.NoIndex)
833 self.setScene(scene)
836 self.setRenderHint(QtGui.QPainter.Antialiasing)
837 self.setOptimizationFlag(self.DontAdjustForAntialiasing, True)
838 self.setViewportUpdateMode(self.SmartViewportUpdate)
839 self.setCacheMode(QtGui.QGraphicsView.CacheBackground)
840 self.setTransformationAnchor(QtGui.QGraphicsView.AnchorUnderMouse)
841 self.setResizeAnchor(QtGui.QGraphicsView.NoAnchor)
842 self.setBackgroundBrush(QtGui.QColor.fromRgb(0, 0, 0))
844 self.action_zoom_in = (
845 qtutils.add_action(self, 'Zoom In',
846 self.zoom_in,
847 QtCore.Qt.Key_Plus,
848 QtCore.Qt.Key_Equal))
850 self.action_zoom_out = (
851 qtutils.add_action(self, 'Zoom Out',
852 self.zoom_out,
853 QtCore.Qt.Key_Minus))
855 self.action_zoom_fit = (
856 qtutils.add_action(self, 'Zoom to Fit',
857 self.view_fit,
858 QtCore.Qt.Key_F))
860 self.action_select_parent = (
861 qtutils.add_action(self, 'Select Parent',
862 self.select_parent,
863 QtCore.Qt.Key_J))
865 self.action_select_oldest_parent = (
866 qtutils.add_action(self, 'Select Oldest Parent',
867 self.select_oldest_parent,
868 'Shift+J'))
870 self.action_select_child = (
871 qtutils.add_action(self, 'Select Child',
872 self.select_child,
873 QtCore.Qt.Key_K))
875 self.action_select_child = (
876 qtutils.add_action(self, 'Select Nth Child',
877 self.select_nth_child,
878 'Shift+K'))
880 self.menu_actions = context_menu_actions(self)
882 sig = signals.commits_selected
883 notifier.add_observer(sig, self.commits_selected)
885 def clear(self):
886 self.scene().clear()
887 self.selected = []
888 self.items.clear()
889 self.rows.clear()
890 self.x_max = 0
891 self.y_min = 0
892 self.commits = []
894 def zoom_in(self):
895 self.scale_view(1.5)
897 def zoom_out(self):
898 self.scale_view(1.0/1.5)
900 def commits_selected(self, commits):
901 if self.selecting:
902 return
903 self.select([commit.sha1 for commit in commits])
905 def contextMenuEvent(self, event):
906 update_menu_actions(self, event)
907 context_menu_event(self, event)
909 def select(self, sha1s):
910 """Select the item for the SHA-1"""
911 self.scene().clearSelection()
912 for sha1 in sha1s:
913 try:
914 item = self.items[sha1]
915 except KeyError:
916 continue
917 item.blockSignals(True)
918 item.setSelected(True)
919 item.blockSignals(False)
920 item_rect = item.sceneTransform().mapRect(item.boundingRect())
921 self.ensureVisible(item_rect)
923 def selected_item(self):
924 """Return the currently selected item"""
925 selected_items = self.selectedItems()
926 if not selected_items:
927 return None
928 return selected_items[0]
930 def selectedItems(self):
931 """Return the currently selected items"""
932 return self.scene().selectedItems()
934 def get_item_by_generation(self, commits, criteria_fn):
935 """Return the item for the commit matching criteria"""
936 if not commits:
937 return None
938 generation = None
939 for commit in commits:
940 if (generation is None or
941 criteria_fn(generation, commit.generation)):
942 sha1 = commit.sha1
943 generation = commit.generation
944 try:
945 return self.items[sha1]
946 except KeyError:
947 return None
949 def oldest_item(self, commits):
950 """Return the item for the commit with the oldest generation number"""
951 return self.get_item_by_generation(commits, lambda a, b: a > b)
953 def newest_item(self, commits):
954 """Return the item for the commit with the newest generation number"""
955 return self.get_item_by_generation(commits, lambda a, b: a < b)
957 def diff_this_selected(self):
958 clicked_sha1 = self.clicked.commit.sha1
959 selected_sha1 = self.selected.commit.sha1
960 self.emit(SIGNAL('diff_commits'), clicked_sha1, selected_sha1)
962 def diff_selected_this(self):
963 clicked_sha1 = self.clicked.commit.sha1
964 selected_sha1 = self.selected.commit.sha1
965 self.emit(SIGNAL('diff_commits'), selected_sha1, clicked_sha1)
967 def create_patch(self):
968 items = self.selectedItems()
969 if not items:
970 return
971 selected_commits = sort_by_generation([n.commit for n in items])
972 sha1s = [c.sha1 for c in selected_commits]
973 all_sha1s = [c.sha1 for c in self.commits]
974 cola.notifier().broadcast(signals.format_patch, sha1s, all_sha1s)
976 def create_branch(self):
977 sha1 = self.clicked.commit.sha1
978 create_new_branch(revision=sha1)
980 def create_tag(self):
981 sha1 = self.clicked.commit.sha1
982 create_tag(revision=sha1)
984 def cherry_pick(self):
985 sha1 = self.clicked.commit.sha1
986 cola.notifier().broadcast(signals.cherry_pick, [sha1])
987 self.notifier.notify_observers(self.notifier.refs_updated)
989 def select_parent(self):
990 """Select the parent with the newest generation number"""
991 selected_item = self.selected_item()
992 if selected_item is None:
993 return
994 parent_item = self.newest_item(selected_item.commit.parents)
995 if parent_item is None:
996 return
997 selected_item.setSelected(False)
998 parent_item.setSelected(True)
999 self.ensureVisible(parent_item.mapRectToScene(parent_item.boundingRect()))
1001 def select_oldest_parent(self):
1002 """Select the parent with the oldest generation number"""
1003 selected_item = self.selected_item()
1004 if selected_item is None:
1005 return
1006 parent_item = self.oldest_item(selected_item.commit.parents)
1007 if parent_item is None:
1008 return
1009 selected_item.setSelected(False)
1010 parent_item.setSelected(True)
1011 self.ensureVisible(parent_item.mapRectToScene(parent_item.boundingRect()))
1013 def select_child(self):
1014 """Select the child with the oldest generation number"""
1015 selected_item = self.selected_item()
1016 if selected_item is None:
1017 return
1018 child_item = self.oldest_item(selected_item.commit.children)
1019 if child_item is None:
1020 return
1021 selected_item.setSelected(False)
1022 child_item.setSelected(True)
1023 self.ensureVisible(child_item.mapRectToScene(child_item.boundingRect()))
1025 def select_nth_child(self):
1026 """Select the Nth child with the newest generation number (N > 1)"""
1027 selected_item = self.selected_item()
1028 if selected_item is None:
1029 return
1030 if len(selected_item.commit.children) > 1:
1031 children = selected_item.commit.children[1:]
1032 else:
1033 children = selected_item.commit.children
1034 child_item = self.newest_item(children)
1035 if child_item is None:
1036 return
1037 selected_item.setSelected(False)
1038 child_item.setSelected(True)
1039 self.ensureVisible(child_item.mapRectToScene(child_item.boundingRect()))
1041 def view_fit(self):
1042 """Fit selected items into the viewport"""
1044 items = self.scene().selectedItems()
1045 if not items:
1046 rect = self.scene().itemsBoundingRect()
1047 else:
1048 x_min = sys.maxint
1049 y_min = sys.maxint
1050 x_max = -sys.maxint
1051 ymax = -sys.maxint
1052 for item in items:
1053 pos = item.pos()
1054 item_rect = item.boundingRect()
1055 x_off = item_rect.width()
1056 y_off = item_rect.height()
1057 x_min = min(x_min, pos.x())
1058 y_min = min(y_min, pos.y())
1059 x_max = max(x_max, pos.x()+x_off)
1060 ymax = max(ymax, pos.y()+y_off)
1061 rect = QtCore.QRectF(x_min, y_min, x_max-x_min, ymax-y_min)
1062 adjust = Commit.width * 2
1063 rect.setX(rect.x() - adjust)
1064 rect.setY(rect.y() - adjust)
1065 rect.setHeight(rect.height() + adjust)
1066 rect.setWidth(rect.width() + adjust)
1067 self.fitInView(rect, QtCore.Qt.KeepAspectRatio)
1068 self.scene().invalidate()
1070 def save_selection(self, event):
1071 if event.button() != QtCore.Qt.LeftButton:
1072 return
1073 elif QtCore.Qt.ShiftModifier != event.modifiers():
1074 return
1075 self.selected = self.selectedItems()
1077 def restore_selection(self, event):
1078 if QtCore.Qt.ShiftModifier != event.modifiers():
1079 return
1080 for item in self.selected:
1081 item.setSelected(True)
1083 def handle_event(self, event_handler, event):
1084 self.update()
1085 self.save_selection(event)
1086 event_handler(self, event)
1087 self.restore_selection(event)
1089 def mousePressEvent(self, event):
1090 if event.button() == QtCore.Qt.MidButton:
1091 pos = event.pos()
1092 self.mouse_start = [pos.x(), pos.y()]
1093 self.saved_matrix = QtGui.QMatrix(self.matrix())
1094 self.is_panning = True
1095 return
1096 if event.button() == QtCore.Qt.RightButton:
1097 event.ignore()
1098 return
1099 if event.button() == QtCore.Qt.LeftButton:
1100 self.pressed = True
1101 self.handle_event(QtGui.QGraphicsView.mousePressEvent, event)
1103 def mouseMoveEvent(self, event):
1104 pos = self.mapToScene(event.pos())
1105 if self.is_panning:
1106 self.pan(event)
1107 return
1108 self.last_mouse[0] = pos.x()
1109 self.last_mouse[1] = pos.y()
1110 self.handle_event(QtGui.QGraphicsView.mouseMoveEvent, event)
1112 def set_selecting(self, selecting):
1113 self.selecting = selecting
1115 def mouseReleaseEvent(self, event):
1116 self.pressed = False
1117 if event.button() == QtCore.Qt.MidButton:
1118 self.is_panning = False
1119 return
1120 self.handle_event(QtGui.QGraphicsView.mouseReleaseEvent, event)
1121 self.selected = []
1123 def pan(self, event):
1124 pos = event.pos()
1125 dx = pos.x() - self.mouse_start[0]
1126 dy = pos.y() - self.mouse_start[1]
1128 if dx == 0 and dy == 0:
1129 return
1131 rect = QtCore.QRect(0, 0, abs(dx), abs(dy))
1132 delta = self.mapToScene(rect).boundingRect()
1134 tx = delta.width()
1135 if dx < 0.0:
1136 tx = -tx
1138 ty = delta.height()
1139 if dy < 0.0:
1140 ty = -ty
1142 matrix = QtGui.QMatrix(self.saved_matrix).translate(tx, ty)
1143 self.setTransformationAnchor(QtGui.QGraphicsView.NoAnchor)
1144 self.setMatrix(matrix)
1146 def wheelEvent(self, event):
1147 """Handle Qt mouse wheel events."""
1148 if event.modifiers() == QtCore.Qt.ControlModifier:
1149 self.wheel_zoom(event)
1150 else:
1151 self.wheel_pan(event)
1153 def wheel_zoom(self, event):
1154 """Handle mouse wheel zooming."""
1155 zoom = math.pow(2.0, event.delta() / 512.0)
1156 factor = (self.matrix()
1157 .scale(zoom, zoom)
1158 .mapRect(QtCore.QRectF(0.0, 0.0, 1.0, 1.0))
1159 .width())
1160 if factor < 0.014 or factor > 42.0:
1161 return
1162 self.setTransformationAnchor(QtGui.QGraphicsView.AnchorUnderMouse)
1163 self.zoom = zoom
1164 self.scale(zoom, zoom)
1166 def wheel_pan(self, event):
1167 """Handle mouse wheel panning."""
1169 if event.delta() < 0:
1170 s = -133.
1171 else:
1172 s = 133.
1173 pan_rect = QtCore.QRectF(0.0, 0.0, 1.0, 1.0)
1174 factor = 1.0 / self.matrix().mapRect(pan_rect).width()
1176 if event.orientation() == QtCore.Qt.Vertical:
1177 matrix = self.matrix().translate(0, s * factor)
1178 else:
1179 matrix = self.matrix().translate(s * factor, 0)
1180 self.setTransformationAnchor(QtGui.QGraphicsView.NoAnchor)
1181 self.setMatrix(matrix)
1183 def scale_view(self, scale):
1184 factor = (self.matrix().scale(scale, scale)
1185 .mapRect(QtCore.QRectF(0, 0, 1, 1))
1186 .width())
1187 if factor < 0.07 or factor > 100:
1188 return
1189 self.zoom = scale
1191 adjust_scrollbars = True
1192 scrollbar = self.verticalScrollBar()
1193 if scrollbar:
1194 value = scrollbar.value()
1195 min_ = scrollbar.minimum()
1196 max_ = scrollbar.maximum()
1197 range_ = max_ - min_
1198 distance = value - min_
1199 nonzero_range = float(range_) != 0.0
1200 if nonzero_range:
1201 scrolloffset = distance/float(range_)
1202 else:
1203 adjust_scrollbars = False
1205 self.setTransformationAnchor(QtGui.QGraphicsView.NoAnchor)
1206 self.scale(scale, scale)
1208 scrollbar = self.verticalScrollBar()
1209 if scrollbar and adjust_scrollbars:
1210 min_ = scrollbar.minimum()
1211 max_ = scrollbar.maximum()
1212 range_ = max_ - min_
1213 value = min_ + int(float(range_) * scrolloffset)
1214 scrollbar.setValue(value)
1216 def add_commits(self, commits):
1217 """Traverse commits and add them to the view."""
1218 self.commits.extend(commits)
1219 scene = self.scene()
1220 for commit in commits:
1221 item = Commit(commit, self.notifier)
1222 self.items[commit.sha1] = item
1223 for ref in commit.tags:
1224 self.items[ref] = item
1225 scene.addItem(item)
1227 self.layout(commits)
1228 self.link(commits)
1230 def link(self, commits):
1231 """Create edges linking commits with their parents"""
1232 scene = self.scene()
1233 for commit in commits:
1234 try:
1235 commit_item = self.items[commit.sha1]
1236 except KeyError:
1237 # TODO - Handle truncated history viewing
1238 pass
1239 for parent in commit.parents:
1240 try:
1241 parent_item = self.items[parent.sha1]
1242 except KeyError:
1243 # TODO - Handle truncated history viewing
1244 continue
1245 edge = Edge(parent_item, commit_item)
1246 scene.addItem(edge)
1248 def layout(self, commits):
1249 x_max = self.x_max
1250 y_min = self.y_min
1251 for commit in commits:
1252 generation = commit.generation
1253 sha1 = commit.sha1
1254 try:
1255 row = self.rows[generation]
1256 except KeyError:
1257 row = self.rows[generation] = []
1259 xpos = (len(commit.parents)-1) * self.x_off
1260 if row:
1261 xpos += row[-1] + self.x_off
1262 ypos = -commit.generation * self.y_off
1264 item = self.items[sha1]
1265 item.setPos(xpos, ypos)
1267 row.append(xpos)
1268 x_max = max(x_max, xpos)
1269 y_min = min(y_min, ypos)
1271 self.x_max = x_max
1272 self.y_min = y_min
1273 self.scene().setSceneRect(-self.x_off/2, y_min-self.y_off,
1274 x_max+self.x_off, abs(y_min)+self.y_off*4)
1276 def sort_by_generation(commits):
1277 commits.sort(cmp=lambda a, b: cmp(a.generation, b.generation))
1278 return commits
1281 def context_menu_actions(self):
1282 return {
1283 'diff_this_selected':
1284 qtutils.add_action(self, 'Diff this -> selected',
1285 self.diff_this_selected),
1286 'diff_selected_this':
1287 qtutils.add_action(self, 'Diff selected -> this',
1288 self.diff_selected_this),
1289 'create_branch':
1290 qtutils.add_action(self, 'Create Branch',
1291 self.create_branch),
1292 'create_patch':
1293 qtutils.add_action(self, 'Create Patch',
1294 self.create_patch),
1295 'create_tag':
1296 qtutils.add_action(self, 'Create Tag',
1297 self.create_tag),
1298 'create_tarball':
1299 qtutils.add_action(self, 'Save As Tarball/Zip...',
1300 lambda: create_tarball(self)),
1301 'cherry_pick':
1302 qtutils.add_action(self, 'Cherry Pick',
1303 self.cherry_pick),
1305 'save_blob':
1306 qtutils.add_action(self, 'Grab File...',
1307 lambda: save_blob_dialog(self)),
1311 def update_menu_actions(self, event):
1312 clicked = self.itemAt(event.pos())
1313 selected_items = self.selectedItems()
1314 has_single_selection = len(selected_items) == 1
1316 has_selection = bool(selected_items)
1317 can_diff = bool(clicked and has_single_selection and
1318 clicked is not selected_items[0])
1320 self.clicked = clicked
1321 if can_diff:
1322 self.selected = selected_items[0]
1323 else:
1324 self.selected = None
1326 self.menu_actions['diff_this_selected'].setEnabled(can_diff)
1327 self.menu_actions['diff_selected_this'].setEnabled(can_diff)
1328 self.menu_actions['create_patch'].setEnabled(has_selection)
1329 self.menu_actions['create_tarball'].setEnabled(has_single_selection)
1330 self.menu_actions['save_blob'].setEnabled(has_single_selection)
1331 self.menu_actions['create_branch'].setEnabled(has_single_selection)
1332 self.menu_actions['create_tag'].setEnabled(has_single_selection)
1333 self.menu_actions['cherry_pick'].setEnabled(has_single_selection)
1336 def context_menu_event(self, event):
1337 menu = QtGui.QMenu(self)
1338 menu.addAction(self.menu_actions['diff_this_selected'])
1339 menu.addAction(self.menu_actions['diff_selected_this'])
1340 menu.addSeparator()
1341 menu.addAction(self.menu_actions['create_branch'])
1342 menu.addAction(self.menu_actions['create_tag'])
1343 menu.addSeparator()
1344 menu.addAction(self.menu_actions['cherry_pick'])
1345 menu.addAction(self.menu_actions['create_patch'])
1346 menu.addAction(self.menu_actions['create_tarball'])
1347 menu.addSeparator()
1348 menu.addAction(self.menu_actions['save_blob'])
1349 menu.exec_(self.mapToGlobal(event.pos()))
1352 def create_tarball(self):
1353 ref = self.clicked.commit.sha1
1354 shortref = ref[:7]
1355 GitArchiveDialog.save(ref, shortref, self)
1358 def save_blob_dialog(self):
1359 return BrowseDialog.browse(self.clicked.commit.sha1)