dag: Allow passing in arbitrary "git log" options
[git-cola.git] / cola / dag / view.py
blob5751869d90a5266b7bab7946e7aabcb740ca4afd
1 import os
2 import sys
3 import math
4 from PyQt4 import QtGui
5 from PyQt4 import QtCore
6 from PyQt4.QtCore import SIGNAL
8 if __name__ == "__main__":
9 # Find the source tree
10 src = os.path.join(os.path.dirname(__file__), '..', '..')
11 sys.path.insert(1, os.path.join(os.path.abspath(src), 'thirdparty'))
12 sys.path.insert(1, os.path.abspath(src))
14 import cola
15 from cola import observable
16 from cola import qtutils
17 from cola import signals
18 from cola import gitcmds
19 from cola import difftool
20 from cola.controllers import createbranch
21 from cola.controllers import createtag
22 from cola.dag.model import DAG
23 from cola.dag.model import RepoReader
24 from cola.prefs import diff_font
25 from cola.qt import DiffSyntaxHighlighter
26 from cola.qt import GitRefLineEdit
27 from cola.views import standard
30 def git_dag(model, parent):
31 """Return a pre-populated git DAG widget."""
32 dag = DAG(model.currentbranch, 1000)
33 view = GitDAGWidget(dag, parent=parent)
34 view.resize_to_desktop()
35 view.show()
36 view.raise_()
37 view.thread.start(QtCore.QThread.LowPriority)
38 return view
41 class DiffWidget(QtGui.QWidget):
42 def __init__(self, notifier, parent=None):
43 QtGui.QWidget.__init__(self, parent)
45 self.diff = QtGui.QTextEdit()
46 self.diff.setLineWrapMode(QtGui.QTextEdit.NoWrap)
47 self.diff.setReadOnly(True)
48 self.diff.setFont(diff_font())
49 self.diff_syn = DiffSyntaxHighlighter(self.diff.document())
51 self._layt = QtGui.QHBoxLayout()
52 self._layt.addWidget(self.diff)
53 self._layt.setMargin(2)
54 self.setLayout(self._layt)
56 sig = signals.commits_selected
57 notifier.add_message_observer(sig, self._commits_selected)
59 def _commits_selected(self, commits):
60 if len(commits) != 1:
61 return
62 commit = commits[0]
63 sha1 = commit.sha1
64 merge = len(commit.parents) > 1
65 self.diff.setText(gitcmds.diff_info(sha1, merge=merge))
66 qtutils.set_clipboard(sha1)
69 class CommitTreeWidgetItem(QtGui.QTreeWidgetItem):
70 def __init__(self, commit, parent=None):
71 QtGui.QListWidgetItem.__init__(self, parent)
72 self.commit = commit
73 self.setText(0, commit.subject)
74 self.setText(1, commit.author)
75 self.setText(2, commit.authdate)
78 class CommitTreeWidget(QtGui.QTreeWidget):
79 def __init__(self, notifier, parent=None):
80 QtGui.QTreeWidget.__init__(self, parent)
81 self.setSelectionMode(self.ContiguousSelection)
82 self.setUniformRowHeights(True)
83 self.setAllColumnsShowFocus(True)
84 self.setAlternatingRowColors(True)
85 self.setRootIsDecorated(False)
86 self.setHeaderLabels(['Subject', 'Author', 'Date'])
88 self._sha1map = {}
89 self._notifier = notifier
90 self._selecting = False
91 self._commits = []
92 self._clicked_item = None
93 self._selected_item = None
94 self._actions = context_menu_actions(self)
96 sig = signals.commits_selected
97 notifier.add_message_observer(sig, self._commits_selected)
99 self.connect(self, SIGNAL('itemSelectionChanged()'),
100 self._item_selection_changed)
102 def contextMenuEvent(self, event):
103 update_actions(self, event)
104 context_menu_event(self, event)
106 def mousePressEvent(self, event):
107 if event.buttons() == QtCore.Qt.RightButton:
108 event.accept()
109 return
110 QtGui.QTreeWidget.mousePressEvent(self, event)
112 def selecting(self):
113 return self._selecting
115 def set_selecting(self, selecting):
116 self._selecting = selecting
118 def _item_selection_changed(self):
119 items = self.selectedItems()
120 if not items:
121 return
122 self.set_selecting(True)
123 sig = signals.commits_selected
124 self._notifier.notify_message_observers(sig, [i.commit for i in items])
125 self.set_selecting(False)
127 def _commits_selected(self, commits):
128 if self.selecting():
129 return
130 self.select([commit.sha1 for commit in commits])
132 def select(self, sha1s):
133 self.clearSelection()
134 for sha1 in sha1s:
135 try:
136 item = self._sha1map[sha1]
137 except KeyError:
138 continue
139 self.blockSignals(True)
140 self.scrollToItem(item)
141 item.setSelected(True)
142 self.blockSignals(False)
144 def adjust_columns(self):
145 width = self.width()-20
146 zero = width*2/3
147 onetwo = width/6
148 self.setColumnWidth(0, zero)
149 self.setColumnWidth(1, onetwo)
150 self.setColumnWidth(2, onetwo)
152 def clear(self):
153 QtGui.QTreeWidget.clear(self)
154 self._sha1map.clear()
155 self._commits = []
157 def add_commits(self, commits):
158 self._commits.extend(commits)
159 items = []
160 for c in reversed(commits):
161 item = CommitTreeWidgetItem(c)
162 items.append(item)
163 self._sha1map[c.sha1] = item
164 for tag in c.tags:
165 self._sha1map[tag] = item
166 self.insertTopLevelItems(0, items)
168 def _diff_this_selected(self):
169 clicked_sha1 = self._clicked_item.commit.sha1
170 selected_sha1 = self._selected_item.commit.sha1
171 difftool.diff_commits(self, clicked_sha1, selected_sha1)
173 def _diff_selected_this(self):
174 clicked_sha1 = self._clicked_item.commit.sha1
175 selected_sha1 = self._selected_item.commit.sha1
176 difftool.diff_commits(self, selected_sha1, clicked_sha1)
178 def _create_patch(self):
179 items = self.selectedItems()
180 if not items:
181 return
182 items.reverse()
183 sha1s = [item.commit.sha1 for item in items]
184 all_sha1s = [c.sha1 for c in self._commits]
185 cola.notifier().broadcast(signals.format_patch, sha1s, all_sha1s)
187 def _create_branch(self):
188 sha1 = self._clicked_item.commit.sha1
189 createbranch.create_new_branch(revision=sha1)
191 def _create_tag(self):
192 sha1 = self._clicked_item.commit.sha1
193 createtag.create_tag(revision=sha1)
195 def _cherry_pick(self):
196 sha1 = self._clicked_item.commit.sha1
197 cola.notifier().broadcast(signals.cherry_pick, [sha1])
200 class GitDAGWidget(standard.StandardDialog):
201 """The git-dag widget."""
202 # Keep us in scope otherwise PyQt kills the widget
203 def __init__(self, dag, parent=None, args=None):
204 standard.StandardDialog.__init__(self, parent=parent)
205 self.dag = dag
206 self.setObjectName('dag')
207 self.setWindowTitle(self.tr('git dag'))
208 self.setMinimumSize(1, 1)
210 self.revlabel = QtGui.QLabel()
211 self.revlabel.setText('git log -')
213 self.revtext = GitRefLineEdit()
214 self.revtext.setText(dag.ref)
216 self.maxresults = QtGui.QSpinBox()
217 self.maxresults.setMinimum(-1)
218 self.maxresults.setMaximum(2**31 - 1)
219 self.maxresults.setValue(dag.count)
221 self.displaybutton = QtGui.QPushButton()
222 self.displaybutton.setText('Display')
224 self.zoom_in = QtGui.QPushButton()
225 self.zoom_in.setIcon(qtutils.theme_icon('zoom-in.png'))
226 self.zoom_in.setFlat(True)
228 self.zoom_out = QtGui.QPushButton()
229 self.zoom_out.setIcon(qtutils.theme_icon('zoom-out.png'))
230 self.zoom_out.setFlat(True)
232 self._buttons_layt = QtGui.QHBoxLayout()
233 self._buttons_layt.setMargin(2)
234 self._buttons_layt.setSpacing(4)
236 self._buttons_layt.addWidget(self.revlabel)
237 self._buttons_layt.addWidget(self.maxresults)
238 self._buttons_layt.addWidget(self.revtext)
239 self._buttons_layt.addWidget(self.displaybutton)
240 self._buttons_layt.addStretch()
241 self._buttons_layt.addWidget(self.zoom_out)
242 self._buttons_layt.addWidget(self.zoom_in)
244 self._commits = {}
245 self._notifier = notifier = observable.Observable()
246 self._notifier.refs_updated = refs_updated = 'refs_updated'
247 self._notifier.add_message_observer(refs_updated, self._display)
249 self._graphview = GraphView(notifier)
250 self._treewidget = CommitTreeWidget(notifier)
251 self._diffwidget = DiffWidget(notifier)
253 self._mainsplitter = QtGui.QSplitter()
254 self._mainsplitter.setOrientation(QtCore.Qt.Horizontal)
255 self._mainsplitter.setChildrenCollapsible(True)
257 self._leftsplitter = QtGui.QSplitter()
258 self._leftsplitter.setOrientation(QtCore.Qt.Vertical)
259 self._leftsplitter.setChildrenCollapsible(True)
260 self._leftsplitter.setStretchFactor(0, 1)
261 self._leftsplitter.setStretchFactor(1, 1)
262 self._leftsplitter.insertWidget(0, self._treewidget)
263 self._leftsplitter.insertWidget(1, self._diffwidget)
265 self._mainsplitter.insertWidget(0, self._leftsplitter)
266 self._mainsplitter.insertWidget(1, self._graphview)
268 self._mainsplitter.setStretchFactor(0, 1)
269 self._mainsplitter.setStretchFactor(1, 1)
271 self._layt = layt = QtGui.QVBoxLayout()
272 layt.setMargin(0)
273 layt.setSpacing(0)
274 layt.addLayout(self._buttons_layt)
275 layt.addWidget(self._mainsplitter)
276 self.setLayout(layt)
278 qtutils.add_close_action(self)
280 self.thread = ReaderThread(self, dag)
282 self.thread.connect(self.thread, self.thread.commits_ready,
283 self.add_commits)
285 self.thread.connect(self.thread, self.thread.done,
286 self.thread_done)
288 self.connect(self._mainsplitter, SIGNAL('splitterMoved(int,int)'),
289 self._splitter_moved)
291 self.connect(self.zoom_in, SIGNAL('pressed()'),
292 self._graphview.zoom_in)
294 self.connect(self.zoom_out, SIGNAL('pressed()'),
295 self._graphview.zoom_out)
297 self.connect(self.maxresults, SIGNAL('valueChanged(int)'),
298 lambda(x): self.dag.set_count(x))
300 self.connect(self.displaybutton, SIGNAL('pressed()'),
301 self._display)
303 def _display(self):
304 new_ref = unicode(self.revtext.text())
305 if not new_ref:
306 return
307 self.stop()
308 self.clear()
309 self.dag.set_ref(new_ref)
310 self.dag.set_count(self.maxresults.value())
311 self.start()
313 def show(self):
314 standard.StandardDialog.show(self)
315 self._mainsplitter.setSizes([self.width()/2, self.width()/2])
316 self._leftsplitter.setSizes([self.height()/3, self.height()*2/3])
317 self._treewidget.adjust_columns()
319 def resizeEvent(self, e):
320 standard.StandardDialog.resizeEvent(self, e)
321 self._treewidget.adjust_columns()
323 def _splitter_moved(self, pos, idx):
324 self._treewidget.adjust_columns()
326 def clear(self):
327 self._graphview.clear()
328 self._treewidget.clear()
329 self._commits.clear()
331 def add_commits(self, commits):
332 # Keep track of commits
333 for commit_obj in commits:
334 self._commits[commit_obj.sha1] = commit_obj
335 for tag in commit_obj.tags:
336 self._commits[tag] = commit_obj
337 self._graphview.add_commits(commits)
338 self._treewidget.add_commits(commits)
340 def thread_done(self):
341 try:
342 commit_obj = self._commits[self.dag.ref]
343 except KeyError:
344 return
345 sig = signals.commits_selected
346 self._notifier.notify_message_observers(sig, [commit_obj])
347 self._graphview.view_fit()
349 def close(self):
350 self.stop()
351 standard.StandardDialog.close(self)
353 def pause(self):
354 self.thread.mutex.lock()
355 self.thread.stop = True
356 self.thread.mutex.unlock()
358 def stop(self):
359 self.thread.abort = True
360 self.thread.wait()
362 def start(self):
363 self.thread.abort = False
364 self.thread.stop = False
365 self.thread.start()
367 def resume(self):
368 self.thread.mutex.lock()
369 self.thread.stop = False
370 self.thread.mutex.unlock()
371 self.thread.condition.wakeOne()
373 def resize_to_desktop(self):
374 desktop = QtGui.QApplication.instance().desktop()
375 width = desktop.width()
376 height = desktop.height()
377 self.resize(width, height)
380 class ReaderThread(QtCore.QThread):
382 commits_ready = QtCore.SIGNAL('commits_ready')
383 done = QtCore.SIGNAL('done')
385 def __init__(self, parent, dag):
386 QtCore.QThread.__init__(self, parent)
387 self.dag = dag
388 self.abort = False
389 self.stop = False
390 self.mutex = QtCore.QMutex()
391 self.condition = QtCore.QWaitCondition()
393 def run(self):
394 repo = RepoReader(self.dag)
395 repo.reset()
396 commits = []
397 for c in repo:
398 self.mutex.lock()
399 if self.stop:
400 self.condition.wait(self.mutex)
401 self.mutex.unlock()
402 if self.abort:
403 repo.reset()
404 return
405 commits.append(c)
406 if len(commits) >= 512:
407 self.emit(self.commits_ready, commits)
408 commits = []
410 if commits:
411 self.emit(self.commits_ready, commits)
412 self.emit(self.done)
415 class Cache(object):
416 pass
419 class Edge(QtGui.QGraphicsItem):
420 _type = QtGui.QGraphicsItem.UserType + 1
421 _arrow_size = 2.0
422 _arrow_extra = (_arrow_size+1.0)/2.0
424 def __init__(self, source, dest,
425 extra=_arrow_extra,
426 arrow_size=_arrow_size):
427 QtGui.QGraphicsItem.__init__(self)
429 self.source_pt = QtCore.QPointF()
430 self.dest_pt = QtCore.QPointF()
431 self.setAcceptedMouseButtons(QtCore.Qt.NoButton)
432 self.source = source
433 self.dest = dest
434 self.setZValue(-2)
436 # Adjust the points to leave a small margin between
437 # the arrow and the commit.
438 dest_pt = Commit._bbox.center()
439 line = QtCore.QLineF(
440 self.mapFromItem(self.source, dest_pt),
441 self.mapFromItem(self.dest, dest_pt))
442 # Magic
443 dx = 22.
444 dy = 11.
445 length = line.length()
446 offset = QtCore.QPointF((line.dx() * dx) / length,
447 (line.dy() * dy) / length)
449 self.source_pt = line.p1() + offset
450 self.dest_pt = line.p2() - offset
452 line = QtCore.QLineF(self.source_pt, self.dest_pt)
453 self.line = line
455 self.pen = QtGui.QPen(QtCore.Qt.gray, 0,
456 QtCore.Qt.DotLine,
457 QtCore.Qt.FlatCap,
458 QtCore.Qt.MiterJoin)
460 # Setup the arrow polygon
461 length = line.length()
462 angle = math.acos(line.dx() / length)
463 if line.dy() >= 0:
464 angle = 2.0 * math.pi - angle
466 dest_x = (self.dest_pt +
467 QtCore.QPointF(math.sin(angle - math.pi/3.) *
468 arrow_size,
469 math.cos(angle - math.pi/3.) *
470 arrow_size))
471 dest_y = (self.dest_pt +
472 QtCore.QPointF(math.sin(angle - math.pi + math.pi/3.) *
473 arrow_size,
474 math.cos(angle - math.pi + math.pi/3.) *
475 arrow_size))
476 self.poly = QtGui.QPolygonF([line.p2(), dest_x, dest_y])
478 width = self.dest_pt.x() - self.source_pt.x()
479 height = self.dest_pt.y() - self.source_pt.y()
480 rect = QtCore.QRectF(self.source_pt, QtCore.QSizeF(width, height))
481 self._bound = rect.normalized().adjusted(-extra, -extra, extra, extra)
483 def type(self, _type=_type):
484 return _type
486 def boundingRect(self):
487 return self._bound
489 def paint(self, painter, option, widget,
490 arrow_size=_arrow_size,
491 gray=QtCore.Qt.gray):
492 # Draw the line
493 painter.setPen(self.pen)
494 painter.drawLine(self.line)
496 # Draw the arrow
497 painter.setBrush(gray)
498 painter.drawPolygon(self.poly)
501 class Commit(QtGui.QGraphicsItem):
502 _type = QtGui.QGraphicsItem.UserType + 2
503 _width = 46.
504 _height = 24.
506 _shape = QtGui.QPainterPath()
507 _shape.addRect(_width/-2., _height/-2., _width, _height)
508 _bbox = _shape.boundingRect()
510 _inner = QtGui.QPainterPath()
511 _inner.addRect(_width/-2.+2., _height/-2.+2, _width-4., _height-4.)
512 _inner = _inner.boundingRect()
514 _selected_color = QtGui.QColor.fromRgb(255, 255, 0)
515 _outline_color = QtGui.QColor.fromRgb(64, 96, 192)
518 _text_options = QtGui.QTextOption()
519 _text_options.setAlignment(QtCore.Qt.AlignCenter)
521 _commit_pen = QtGui.QPen()
522 _commit_pen.setWidth(1.0)
523 _commit_pen.setColor(_outline_color)
525 _commit_color = QtGui.QColor.fromRgb(128, 222, 255)
526 _commit_selected_color = QtGui.QColor.fromRgb(32, 64, 255)
527 _merge_color = QtGui.QColor.fromRgb(255, 255, 255)
529 def __init__(self, commit,
530 notifier,
531 selectable=QtGui.QGraphicsItem.ItemIsSelectable,
532 cursor=QtCore.Qt.PointingHandCursor,
533 xpos=_width/2.+1.,
534 commit_color=_commit_color,
535 commit_selected_color=_commit_selected_color,
536 merge_color=_merge_color):
538 QtGui.QGraphicsItem.__init__(self)
540 self.setZValue(0)
541 self.setFlag(selectable)
542 self.setCursor(cursor)
544 self.commit = commit
545 self._notifier = notifier
547 if commit.tags:
548 self.label = Label(commit)
549 self.label.setParentItem(self)
550 self.label.setPos(xpos, 0.)
551 else:
552 self.label = None
554 if len(commit.parents) > 1:
555 self.commit_color = merge_color
556 else:
557 self.commit_color = commit_color
558 self.text_pen = QtCore.Qt.black
559 self.sha1_text = commit.sha1[:8]
561 self.pressed = False
562 self.dragged = False
565 # Overridden Qt methods
568 def blockSignals(self, blocked):
569 self._notifier.notification_enabled = not blocked
571 def itemChange(self, change, value):
572 if change == QtGui.QGraphicsItem.ItemSelectedHasChanged:
573 # Broadcast selection to other widgets
574 selected_items = self.scene().selectedItems()
575 commits = [item.commit for item in selected_items]
576 self.scene().parent().set_selecting(True)
577 sig = signals.commits_selected
578 self._notifier.notify_message_observers(sig, commits)
579 self.scene().parent().set_selecting(False)
581 # Cache the pen for use in paint()
582 if value.toPyObject():
583 self.commit_color = self._commit_selected_color
584 self.text_pen = QtCore.Qt.white
585 color = self._selected_color
586 else:
587 self.text_pen = QtCore.Qt.black
588 if len(self.commit.parents) > 1:
589 self.commit_color = self._merge_color
590 else:
591 self.commit_color = self._commit_color
592 color = self._outline_color
593 commit_pen = QtGui.QPen()
594 commit_pen.setWidth(1.0)
595 commit_pen.setColor(color)
596 self._commit_pen = commit_pen
598 return QtGui.QGraphicsItem.itemChange(self, change, value)
600 def type(self, _type=_type):
601 return _type
603 def boundingRect(self, _bbox=_bbox):
604 return _bbox
606 def shape(self, _shape=_shape):
607 return _shape
609 def paint(self, painter, option, widget,
610 inner=_inner,
611 text_options=_text_options,
612 cache=Cache):
614 # Do not draw outside the exposed rect
615 painter.setClipRect(option.exposedRect)
617 # Draw ellipse
618 painter.setPen(self._commit_pen)
619 painter.setBrush(self.commit_color)
620 painter.drawEllipse(inner)
622 # Draw text
623 try:
624 font = cache.font
625 except AttributeError:
626 font = cache.font = painter.font()
627 font.setPointSize(5)
628 painter.setFont(font)
629 painter.setPen(self.text_pen)
630 painter.drawText(inner, self.sha1_text, text_options)
632 def mousePressEvent(self, event):
633 QtGui.QGraphicsItem.mousePressEvent(self, event)
634 self.pressed = True
635 self.selected = self.isSelected()
637 def mouseMoveEvent(self, event):
638 if self.pressed:
639 self.dragged = True
640 QtGui.QGraphicsItem.mouseMoveEvent(self, event)
642 def mouseReleaseEvent(self, event):
643 QtGui.QGraphicsItem.mouseReleaseEvent(self, event)
644 if (not self.dragged and
645 self.selected and
646 event.button() == QtCore.Qt.LeftButton):
647 return
648 self.pressed = False
649 self.dragged = False
652 class Label(QtGui.QGraphicsItem):
653 _type = QtGui.QGraphicsItem.UserType + 3
655 _width = 72
656 _height = 18
658 _shape = QtGui.QPainterPath()
659 _shape.addRect(0, 0, _width, _height)
661 _bbox = _shape.boundingRect()
663 _text_options = QtGui.QTextOption()
664 _text_options.setAlignment(QtCore.Qt.AlignCenter)
665 _text_options.setAlignment(QtCore.Qt.AlignVCenter)
667 _black = QtCore.Qt.black
669 def __init__(self, commit,
670 other_color=QtGui.QColor.fromRgb(255, 255, 64),
671 head_color=QtGui.QColor.fromRgb(64, 255, 64),
672 width=_width,
673 height=_height):
674 QtGui.QGraphicsItem.__init__(self)
675 self.setZValue(-1)
677 # Starts with enough space for two tags. Any more and the commit
678 # needs to be taller to accomodate.
680 self.commit = commit
681 height = len(commit.tags) * height/2. + 4. # +6 padding
683 self.label_box = QtCore.QRectF(0., -height/2., width, height)
684 self.text_box = QtCore.QRectF(2., -height/2., width-4., height)
685 self.tag_text = '\n'.join(commit.tags)
687 if 'HEAD' in commit.tags:
688 self.color = head_color
689 else:
690 self.color = other_color
692 self.pen = QtGui.QPen()
693 self.pen.setColor(self.color.darker())
694 self.pen.setWidth(1.0)
696 def type(self, _type=_type):
697 return _type
699 def boundingRect(self, _bbox=_bbox):
700 return _bbox
702 def shape(self, _shape=_shape):
703 return _shape
705 def paint(self, painter, option, widget,
706 text_options=_text_options,
707 black=_black,
708 cache=Cache):
709 # Draw tags
710 painter.setBrush(self.color)
711 painter.setPen(self.pen)
712 painter.drawRoundedRect(self.label_box, 4, 4)
713 try:
714 font = cache.font
715 except AttributeError:
716 font = cache.font = painter.font()
717 font.setPointSize(5)
718 painter.setFont(font)
719 painter.setPen(black)
720 painter.drawText(self.text_box, self.tag_text, text_options)
723 class GraphView(QtGui.QGraphicsView):
724 def __init__(self, notifier):
725 QtGui.QGraphicsView.__init__(self)
727 self._xoff = 132
728 self._yoff = 32
729 self._xmax = 0
730 self._ymin = 0
732 self._selected = []
733 self._notifier = notifier
734 self._commits = []
735 self._items = {}
736 self._selected_item = None
737 self._clicked_item = None
739 self._rows = {}
741 self._panning = False
742 self._pressed = False
743 self._selecting = False
744 self._last_mouse = [0, 0]
746 self._zoom = 2
747 self.scale(self._zoom, self._zoom)
748 self.setDragMode(self.RubberBandDrag)
750 scene = QtGui.QGraphicsScene(self)
751 scene.setItemIndexMethod(QtGui.QGraphicsScene.NoIndex)
752 self.setScene(scene)
754 self.setRenderHint(QtGui.QPainter.Antialiasing)
755 self.setOptimizationFlag(self.DontAdjustForAntialiasing, True)
756 self.setViewportUpdateMode(self.SmartViewportUpdate)
757 self.setCacheMode(QtGui.QGraphicsView.CacheBackground)
758 self.setTransformationAnchor(QtGui.QGraphicsView.AnchorUnderMouse)
759 self.setResizeAnchor(QtGui.QGraphicsView.NoAnchor)
760 self.setBackgroundBrush(QtGui.QColor.fromRgb(0, 0, 0))
762 self._action_zoom_in = (
763 qtutils.add_action(self, 'Zoom In',
764 self.zoom_in,
765 QtCore.Qt.Key_Plus,
766 QtCore.Qt.Key_Equal))
768 self._action_zoom_out = (
769 qtutils.add_action(self, 'Zoom Out',
770 self.zoom_out,
771 QtCore.Qt.Key_Minus))
773 self._action_zoom_fit = (
774 qtutils.add_action(self, 'Zoom to Fit',
775 self.view_fit,
776 QtCore.Qt.Key_F))
778 self._action_select_parent = (
779 qtutils.add_action(self, 'Select Parent',
780 self._select_parent,
781 QtCore.Qt.Key_J))
783 self._action_select_oldest_parent = (
784 qtutils.add_action(self, 'Select Oldest Parent',
785 self._select_oldest_parent,
786 'Shift+J'))
788 self._action_select_child = (
789 qtutils.add_action(self, 'Select Child',
790 self._select_child,
791 QtCore.Qt.Key_K))
793 self._action_select_child = (
794 qtutils.add_action(self, 'Select Nth Child',
795 self._select_nth_child,
796 'Shift+K'))
798 self._actions = context_menu_actions(self)
800 sig = signals.commits_selected
801 notifier.add_message_observer(sig, self._commits_selected)
803 def zoom_in(self):
804 self._scale_view(1.5)
806 def zoom_out(self):
807 self._scale_view(1.0/1.5)
809 def _commits_selected(self, commits):
810 if self.selecting():
811 return
812 self.select([commit.sha1 for commit in commits])
814 def contextMenuEvent(self, event):
815 update_actions(self, event)
816 context_menu_event(self, event)
818 def select(self, sha1s):
819 """Select the item for the SHA-1"""
820 self.scene().clearSelection()
821 for sha1 in sha1s:
822 try:
823 item = self._items[sha1]
824 except KeyError:
825 continue
826 item.blockSignals(True)
827 item.setSelected(True)
828 item.blockSignals(False)
829 self.ensureVisible(item.mapRectToScene(item.boundingRect()))
831 def selected_item(self):
832 """Return the currently selected item"""
833 selected_items = self.selectedItems()
834 if not selected_items:
835 return None
836 return selected_items[0]
838 def selectedItems(self):
839 """Return the currently selected items"""
840 return self.scene().selectedItems()
842 def get_item_by_generation(self, commits, criteria_fn):
843 """Return the item for the commit matching criteria"""
844 if not commits:
845 return None
846 generation = None
847 for commit in commits:
848 if (generation is None or
849 criteria_fn(generation, commit.generation)):
850 sha1 = commit.sha1
851 generation = commit.generation
852 try:
853 return self._items[sha1]
854 except KeyError:
855 return None
857 def oldest_item(self, commits):
858 """Return the item for the commit with the oldest generation number"""
859 return self.get_item_by_generation(commits, lambda a, b: a > b)
861 def newest_item(self, commits):
862 """Return the item for the commit with the newest generation number"""
863 return self.get_item_by_generation(commits, lambda a, b: a < b)
865 def _diff_this_selected(self):
866 clicked_sha1 = self._clicked_item.commit.sha1
867 selected_sha1 = self._selected_item.commit.sha1
868 difftool.diff_commits(self, clicked_sha1, selected_sha1)
870 def _diff_selected_this(self):
871 clicked_sha1 = self._clicked_item.commit.sha1
872 selected_sha1 = self._selected_item.commit.sha1
873 difftool.diff_commits(self, selected_sha1, clicked_sha1)
875 def _create_patch(self):
876 items = self.selectedItems()
877 if not items:
878 return
879 selected_commits = sort_by_generation([n.commit for n in items])
880 sha1s = [c.sha1 for c in selected_commits]
881 all_sha1s = [c.sha1 for c in self._commits]
882 cola.notifier().broadcast(signals.format_patch, sha1s, all_sha1s)
884 def _create_branch(self):
885 sha1 = self._clicked_item.commit.sha1
886 createbranch.create_new_branch(revision=sha1)
888 def _create_tag(self):
889 sha1 = self._clicked_item.commit.sha1
890 createtag.create_tag(revision=sha1)
892 def _cherry_pick(self):
893 sha1 = self._clicked_item.commit.sha1
894 cola.notifier().broadcast(signals.cherry_pick, [sha1])
895 self._notifier.notify_message_observers(self._notifier.refs_updated)
897 def _select_parent(self):
898 """Select the parent with the newest generation number"""
899 selected_item = self.selected_item()
900 if selected_item is None:
901 return
902 parent_item = self.newest_item(selected_item.commit.parents)
903 if parent_item is None:
904 return
905 selected_item.setSelected(False)
906 parent_item.setSelected(True)
907 self.ensureVisible(parent_item.mapRectToScene(parent_item.boundingRect()))
909 def _select_oldest_parent(self):
910 """Select the parent with the oldest generation number"""
911 selected_item = self.selected_item()
912 if selected_item is None:
913 return
914 parent_item = self.oldest_item(selected_item.commit.parents)
915 if parent_item is None:
916 return
917 selected_item.setSelected(False)
918 parent_item.setSelected(True)
919 self.ensureVisible(parent_item.mapRectToScene(parent_item.boundingRect()))
921 def _select_child(self):
922 """Select the child with the oldest generation number"""
923 selected_item = self.selected_item()
924 if selected_item is None:
925 return
926 child_item = self.oldest_item(selected_item.commit.children)
927 if child_item is None:
928 return
929 selected_item.setSelected(False)
930 child_item.setSelected(True)
931 self.ensureVisible(child_item.mapRectToScene(child_item.boundingRect()))
933 def _select_nth_child(self):
934 """Select the Nth child with the newest generation number (N > 1)"""
935 selected_item = self.selected_item()
936 if selected_item is None:
937 return
938 if len(selected_item.commit.children) > 1:
939 children = selected_item.commit.children[1:]
940 else:
941 children = selected_item.commit.children
942 child_item = self.newest_item(children)
943 if child_item is None:
944 return
945 selected_item.setSelected(False)
946 child_item.setSelected(True)
947 self.ensureVisible(child_item.mapRectToScene(child_item.boundingRect()))
949 def view_fit(self):
950 """Fit selected items into the viewport"""
952 items = self.scene().selectedItems()
953 if not items:
954 rect = self.scene().itemsBoundingRect()
955 else:
956 xmin = sys.maxint
957 ymin = sys.maxint
958 xmax = -sys.maxint
959 ymax = -sys.maxint
960 for item in items:
961 pos = item.pos()
962 item_rect = item.boundingRect()
963 xoff = item_rect.width()
964 yoff = item_rect.height()
965 xmin = min(xmin, pos.x())
966 ymin = min(ymin, pos.y())
967 xmax = max(xmax, pos.x()+xoff)
968 ymax = max(ymax, pos.y()+yoff)
969 rect = QtCore.QRectF(xmin, ymin, xmax-xmin, ymax-ymin)
970 adjust = Commit._width
971 rect.setX(rect.x() - adjust)
972 rect.setY(rect.y() - adjust)
973 rect.setHeight(rect.height() + adjust)
974 rect.setWidth(rect.width() + adjust)
975 self.fitInView(rect, QtCore.Qt.KeepAspectRatio)
976 self.scene().invalidate()
978 def _save_selection(self, event):
979 if event.button() != QtCore.Qt.LeftButton:
980 return
981 elif QtCore.Qt.ShiftModifier != event.modifiers():
982 return
983 self._selected = self.selectedItems()
985 def _restore_selection(self, event):
986 if QtCore.Qt.ShiftModifier != event.modifiers():
987 return
988 for item in self._selected:
989 item.setSelected(True)
991 def _handle_event(self, eventhandler, event):
992 self.update()
993 self._save_selection(event)
994 eventhandler(self, event)
995 self._restore_selection(event)
997 def mousePressEvent(self, event):
998 if event.button() == QtCore.Qt.MidButton:
999 pos = event.pos()
1000 self._mouse_start = [pos.x(), pos.y()]
1001 self._saved_matrix = QtGui.QMatrix(self.matrix())
1002 self._panning = True
1003 return
1004 if event.button() == QtCore.Qt.RightButton:
1005 event.ignore()
1006 return
1007 if event.button() == QtCore.Qt.LeftButton:
1008 self._pressed = True
1009 self._handle_event(QtGui.QGraphicsView.mousePressEvent, event)
1011 def mouseMoveEvent(self, event):
1012 pos = self.mapToScene(event.pos())
1013 if self._panning:
1014 self._pan(event)
1015 return
1016 self._last_mouse[0] = pos.x()
1017 self._last_mouse[1] = pos.y()
1018 self._handle_event(QtGui.QGraphicsView.mouseMoveEvent, event)
1020 def selecting(self):
1021 return self._selecting
1023 def set_selecting(self, selecting):
1024 self._selecting = selecting
1026 def mouseReleaseEvent(self, event):
1027 self._pressed = False
1028 if event.button() == QtCore.Qt.MidButton:
1029 self._panning = False
1030 return
1031 self._handle_event(QtGui.QGraphicsView.mouseReleaseEvent, event)
1032 self._selected = []
1034 def _pan(self, event):
1035 pos = event.pos()
1036 dx = pos.x() - self._mouse_start[0]
1037 dy = pos.y() - self._mouse_start[1]
1039 if dx == 0 and dy == 0:
1040 return
1042 rect = QtCore.QRect(0, 0, abs(dx), abs(dy))
1043 delta = self.mapToScene(rect).boundingRect()
1045 tx = delta.width()
1046 if dx < 0.0:
1047 tx = -tx
1049 ty = delta.height()
1050 if dy < 0.0:
1051 ty = -ty
1053 matrix = QtGui.QMatrix(self._saved_matrix).translate(tx, ty)
1054 self.setTransformationAnchor(QtGui.QGraphicsView.NoAnchor)
1055 self.setMatrix(matrix)
1057 def wheelEvent(self, event):
1058 """Handle Qt mouse wheel events."""
1059 if event.modifiers() == QtCore.Qt.ControlModifier:
1060 self._wheel_zoom(event)
1061 else:
1062 self._wheel_pan(event)
1064 def _wheel_zoom(self, event):
1065 """Handle mouse wheel zooming."""
1066 zoom = math.pow(2.0, event.delta() / 512.0)
1067 factor = (self.matrix()
1068 .scale(zoom, zoom)
1069 .mapRect(QtCore.QRectF(0.0, 0.0, 1.0, 1.0))
1070 .width())
1071 if factor < 0.014 or factor > 42.0:
1072 return
1073 self.setTransformationAnchor(QtGui.QGraphicsView.AnchorUnderMouse)
1074 self._zoom = zoom
1075 self.scale(zoom, zoom)
1077 def _wheel_pan(self, event):
1078 """Handle mouse wheel panning."""
1080 if event.delta() < 0:
1081 s = -133.
1082 else:
1083 s = 133.
1084 pan_rect = QtCore.QRectF(0.0, 0.0, 1.0, 1.0)
1085 factor = 1.0 / self.matrix().mapRect(pan_rect).width()
1087 if event.orientation() == QtCore.Qt.Vertical:
1088 matrix = self.matrix().translate(0, s * factor)
1089 else:
1090 matrix = self.matrix().translate(s * factor, 0)
1091 self.setTransformationAnchor(QtGui.QGraphicsView.NoAnchor)
1092 self.setMatrix(matrix)
1094 def _scale_view(self, scale):
1095 factor = (self.matrix().scale(scale, scale)
1096 .mapRect(QtCore.QRectF(0, 0, 1, 1))
1097 .width())
1098 if factor < 0.07 or factor > 100:
1099 return
1100 self._zoom = scale
1102 adjust_scrollbars = True
1103 scrollbar = self.verticalScrollBar()
1104 if scrollbar:
1105 value = scrollbar.value()
1106 min_ = scrollbar.minimum()
1107 max_ = scrollbar.maximum()
1108 range_ = max_ - min_
1109 distance = value - min_
1110 nonzero_range = float(range_) != 0.0
1111 if nonzero_range:
1112 scrolloffset = distance/float(range_)
1113 else:
1114 adjust_scrollbars = False
1116 self.setTransformationAnchor(QtGui.QGraphicsView.NoAnchor)
1117 self.scale(scale, scale)
1119 scrollbar = self.verticalScrollBar()
1120 if scrollbar and adjust_scrollbars:
1121 min_ = scrollbar.minimum()
1122 max_ = scrollbar.maximum()
1123 range_ = max_ - min_
1124 value = min_ + int(float(range_) * scrolloffset)
1125 scrollbar.setValue(value)
1127 def clear(self):
1128 self.scene().clear()
1129 self._selected = []
1130 self._items.clear()
1131 self._rows.clear()
1132 self._xmax = 0
1133 self._ymin = 0
1134 self._commits = []
1136 def add_commits(self, commits):
1137 """Traverse commits and add them to the view."""
1138 self._commits.extend(commits)
1139 scene = self.scene()
1140 for commit in commits:
1141 item = Commit(commit, self._notifier)
1142 self._items[commit.sha1] = item
1143 for ref in commit.tags:
1144 self._items[ref] = item
1145 scene.addItem(item)
1147 self.layout(commits)
1148 self.link(commits)
1150 def link(self, commits):
1151 """Create edges linking commits with their parents"""
1152 scene = self.scene()
1153 for commit in commits:
1154 try:
1155 commit_item = self._items[commit.sha1]
1156 except KeyError:
1157 # TODO - Handle truncated history viewing
1158 pass
1159 for parent in commit.parents:
1160 try:
1161 parent_item = self._items[parent.sha1]
1162 except KeyError:
1163 # TODO - Handle truncated history viewing
1164 continue
1165 edge = Edge(parent_item, commit_item)
1166 scene.addItem(edge)
1168 def layout(self, commits):
1169 xmax = self._xmax
1170 ymin = self._ymin
1171 for commit in commits:
1172 generation = commit.generation
1173 sha1 = commit.sha1
1174 try:
1175 row = self._rows[generation]
1176 except KeyError:
1177 row = self._rows[generation] = []
1179 xpos = (len(commit.parents)-1) * self._xoff
1180 if row:
1181 xpos += row[-1] + self._xoff
1182 ypos = -commit.generation * self._yoff
1184 item = self._items[sha1]
1185 item.setPos(xpos, ypos)
1187 row.append(xpos)
1188 xmax = max(xmax, xpos)
1189 ymin = min(ymin, ypos)
1191 self._xmax = xmax
1192 self._ymin = ymin
1193 self.scene().setSceneRect(self._xoff*-2, ymin-self._yoff*2,
1194 xmax+self._xoff*3, abs(ymin)+self._yoff*4)
1196 def sort_by_generation(commits):
1197 commits.sort(cmp=lambda a, b: cmp(a.generation, b.generation))
1198 return commits
1201 def context_menu_actions(self):
1202 return {
1203 'diff_this_selected':
1204 qtutils.add_action(self, 'Diff this -> selected',
1205 self._diff_this_selected),
1206 'diff_selected_this':
1207 qtutils.add_action(self, 'Diff selected -> this',
1208 self._diff_selected_this),
1209 'create_patch':
1210 qtutils.add_action(self, 'Create Patch',
1211 self._create_patch),
1212 'create_branch':
1213 qtutils.add_action(self, 'Create Branch',
1214 self._create_branch),
1215 'create_tag':
1216 qtutils.add_action(self, 'Create Tag',
1217 self._create_tag),
1218 'cherry_pick':
1219 qtutils.add_action(self, 'Cherry Pick',
1220 self._cherry_pick),
1224 def update_actions(self, event):
1225 clicked_item = self.itemAt(event.pos())
1226 selected_items = self.selectedItems()
1227 has_single_selection = len(selected_items) == 1
1229 has_selection = bool(selected_items)
1230 can_diff = bool(clicked_item and has_single_selection and
1231 clicked_item is not selected_items[0])
1233 self._clicked_item = clicked_item
1234 if can_diff:
1235 self._selected_item = selected_items[0]
1236 else:
1237 self._selected_item = None
1239 self._actions['diff_this_selected'].setEnabled(can_diff)
1240 self._actions['diff_selected_this'].setEnabled(can_diff)
1241 self._actions['create_patch'].setEnabled(has_selection)
1242 self._actions['create_branch'].setEnabled(has_single_selection)
1243 self._actions['create_tag'].setEnabled(has_single_selection)
1244 self._actions['cherry_pick'].setEnabled(has_single_selection)
1247 def context_menu_event(self, event):
1248 menu = QtGui.QMenu(self)
1249 menu.addAction(self._actions['diff_this_selected'])
1250 menu.addAction(self._actions['diff_selected_this'])
1251 menu.addSeparator()
1252 menu.addAction(self._actions['create_patch'])
1253 menu.addAction(self._actions['create_branch'])
1254 menu.addAction(self._actions['create_tag'])
1255 menu.addAction(self._actions['cherry_pick'])
1256 menu.exec_(self.mapToGlobal(event.pos()))
1259 if __name__ == "__main__":
1260 from cola import app
1262 model = cola.model()
1263 model.use_worktree(os.getcwd())
1264 model.update_status()
1266 app = app.ColaApplication(sys.argv)
1267 view = git_dag(model, app.activeWindow())
1268 sys.exit(app.exec_())