views.dag: Decrease the minimum zoom factor
[git-cola.git] / cola / views / dag.py
blob2bb1ccaa38b4d5ed550570678b9f423713577421
1 import os
2 import sys
3 import math
4 from PyQt4 import QtGui
5 from PyQt4 import QtCore
7 if __name__ == "__main__":
8 # Find the source tree
9 src = os.path.join(os.path.dirname(__file__), '..', '..')
10 sys.path.insert(0, os.path.abspath(src))
12 from cola import qtutils
13 from cola.models import commit
14 from cola.views import standard
15 from cola.compat import set
16 from cola.decorators import memoize
19 def git_dag(log_args=None, parent=None):
20 """Return a pre-populated git DAG widget."""
21 view = GitDAGWidget(parent)
22 view.thread.start(QtCore.QThread.LowPriority)
23 view.show()
24 return view
27 class GitDAGWidget(standard.StandardDialog):
28 """The git-dag widget."""
29 # Keep us in scope otherwise PyQt kills the widget
30 _instances = set()
32 def delete(self):
33 self._instances.remove(self)
35 def __init__(self, parent=None, args=None):
36 standard.StandardDialog.__init__(self, parent)
37 self._instances.add(self)
39 self.setObjectName('dag')
40 self.setWindowTitle(self.tr('git dag'))
41 self.setMinimumSize(1, 1)
42 self.resize(777, 666)
44 self._graphview = GraphView()
45 layt = QtGui.QHBoxLayout()
46 layt.setMargin(1)
47 layt.addWidget(self._graphview)
48 self.setLayout(layt)
50 qtutils.add_close_action(self)
51 if not parent:
52 qtutils.center_on_screen(self)
54 self.thread = ReaderThread(self, args)
55 self.thread.connect(self.thread,
56 self.thread.commit_ready,
57 self._add_commit)
59 def _add_commit(self, sha1):
60 c = self.thread.repo[sha1]
61 self.add_commits([c])
63 def add_commits(self, commits):
64 self._graphview.add_commits(commits)
66 def close(self):
67 self.thread.abort = True
68 self.thread.wait()
69 standard.StandardDialog.close(self)
71 def pause(self):
72 self.thread.mutex.lock()
73 self.thread.stop = True
74 self.thread.mutex.unlock()
76 def resume(self):
77 self.thread.mutex.lock()
78 self.thread.stop = False
79 self.thread.mutex.unlock()
80 self.thread.condition.wakeOne()
83 class ReaderThread(QtCore.QThread):
85 commit_ready = QtCore.SIGNAL('commit_ready')
87 def __init__(self, parent, args):
88 super(ReaderThread, self).__init__(parent)
89 self.repo = commit.RepoReader(args=args)
90 self.abort = False
91 self.stop = False
92 self.mutex = QtCore.QMutex()
93 self.condition = QtCore.QWaitCondition()
95 def run(self):
96 for commit in self.repo:
97 self.mutex.lock()
98 if self.stop:
99 self.condition.wait(self.mutex)
100 self.mutex.unlock()
101 if self.abort:
102 self.repo.reset()
103 return
104 self.emit(self.commit_ready, commit.sha1)
107 _arrow_size = 4.0
108 _arrow_extra = (_arrow_size + 1.0) / 2.0
110 class Edge(QtGui.QGraphicsItem):
111 _type = QtGui.QGraphicsItem.UserType + 2
113 def __init__(self, source, dest):
114 QtGui.QGraphicsItem.__init__(self)
116 self.source_pt = QtCore.QPointF()
117 self.dest_pt = QtCore.QPointF()
118 self.setAcceptedMouseButtons(QtCore.Qt.NoButton)
119 self.source = source
120 self.dest = dest
121 self.source.add_edge(self)
122 self.dest.add_edge(self)
123 self.setZValue(-2)
124 self.adjust()
126 def type(self, _type=_type):
127 return _type
129 def adjust(self):
130 if not self.source or not self.dest:
131 return
133 dest_glyph_pt = self.dest.glyph().center()
134 line = QtCore.QLineF(
135 self.mapFromItem(self.source, dest_glyph_pt),
136 self.mapFromItem(self.dest, dest_glyph_pt))
138 length = line.length()
139 if length == 0.0:
140 return
142 offset = QtCore.QPointF((line.dx() * 23) / length,
143 (line.dy() * 9) / length)
145 self.prepareGeometryChange()
146 self.source_pt = line.p1() + offset
147 self.dest_pt = line.p2() - offset
149 @memoize
150 def boundingRect(self, _extra=_arrow_extra):
151 if not self.source or not self.dest:
152 return QtCore.QRectF()
153 width = self.dest_pt.x() - self.source_pt.x()
154 height = self.dest_pt.y() - self.source_pt.y()
155 rect = QtCore.QRectF(self.source_pt, QtCore.QSizeF(width, height))
156 return rect.normalized().adjusted(-_extra, -_extra, _extra, _extra)
158 def paint(self, painter, option, widget, _arrow_size=_arrow_size):
159 if not self.source or not self.dest:
160 return
161 # Draw the line itself.
162 line = QtCore.QLineF(self.source_pt, self.dest_pt)
163 length = line.length()
164 if length > 2 ** 13:
165 return
167 painter.setPen(QtGui.QPen(QtCore.Qt.gray, 0,
168 QtCore.Qt.DotLine,
169 QtCore.Qt.FlatCap,
170 QtCore.Qt.MiterJoin))
171 painter.drawLine(line)
173 # Draw the arrows if there's enough room.
174 angle = math.acos(line.dx() / length)
175 if line.dy() >= 0:
176 angle = 2.0 * math.pi - angle
178 dest_x = (self.dest_pt +
179 QtCore.QPointF(math.sin(angle - math.pi/3.) *
180 _arrow_size,
181 math.cos(angle - math.pi/3.) *
182 _arrow_size))
183 dest_y = (self.dest_pt +
184 QtCore.QPointF(math.sin(angle - math.pi + math.pi/3.) *
185 _arrow_size,
186 math.cos(angle - math.pi + math.pi/3.) *
187 _arrow_size))
189 painter.setBrush(QtCore.Qt.gray)
190 painter.drawPolygon(QtGui.QPolygonF([line.p2(), dest_x, dest_y]))
193 class Node(QtGui.QGraphicsItem):
194 _type = QtGui.QGraphicsItem.UserType + 1
195 _width = 180
196 _height = 18
198 _shape = QtGui.QPainterPath()
199 _shape.addRect(_width/-2., _height/-2., _width, _height)
201 _bound = _shape.boundingRect()
202 _glyph = QtCore.QRectF(-_width/2., -9, _width/4., 18)
204 def __init__(self, commit):
205 QtGui.QGraphicsItem.__init__(self)
206 self.setZValue(0)
207 self.setFlag(QtGui.QGraphicsItem.ItemIsSelectable)
208 self.commit = commit
209 # Starts with enough space for two tags. Any more and the node
210 # needs to be taller to accomodate.
211 if len(self.commit.tags) > 1:
212 self._height = len(self.commit.tags) * 9 + 6 # +6 padding
213 self._edges = []
215 self._colors = {}
216 self._colors['bg'] = QtGui.QColor.fromRgb(16, 16, 16)
217 self._colors['selected'] = QtGui.QColor.fromRgb(192, 192, 16)
218 self._colors['outline'] = QtGui.QColor.fromRgb(0, 0, 0)
219 self._colors['node'] = QtGui.QColor.fromRgb(255, 111, 69)
220 self._colors['decorations'] = QtGui.QColor.fromRgb(255, 255, 42)
222 self._grad = QtGui.QLinearGradient(0.0, 0.0, 0.0, self._height)
223 self._grad.setColorAt(0, self._colors['node'])
224 self._grad.setColorAt(1, self._colors['node'].darker())
226 self.pressed = False
227 self.dragged = False
228 self.skipped = False
230 def type(self, _type=_type):
231 return _type
233 def add_edge(self, edge):
234 self._edges.append(edge)
236 def boundingRect(self, _bound=_bound):
237 return _bound
239 def shape(self, _shape=_shape):
240 return _shape
242 def glyph(self, _glyph=_glyph):
243 """Provides location of the glyph representing this node
245 The node contains a glyph (a circle or ellipse) representing the
246 node, as well as other text alongside the glyph. Knowing the
247 location of the glyph, rather than the entire node allows us to
248 make edges point at the center of the glyph, rather than at the
249 center of the entire node.
251 return _glyph
253 def paint(self, painter, option, widget):
254 if self.isSelected():
255 painter.setPen(self._colors['selected'])
256 else:
257 painter.setPen(self._colors['outline'])
258 painter.setBrush(self._grad)
260 # Draw glyph
261 painter.drawEllipse(self.glyph())
262 sha1_text = self.commit.sha1
263 font = painter.font()
264 font.setPointSize(5)
265 painter.setFont(font)
266 painter.setPen(QtCore.Qt.black)
268 text_options = QtGui.QTextOption()
269 text_options.setAlignment(QtCore.Qt.AlignCenter)
270 painter.drawText(self.glyph(), sha1_text, text_options)
272 # Draw tags
273 if not self.commit.tags:
274 return
275 # Those 2's affecting width are just for padding
276 text_box = QtCore.QRectF(-self._width/4.+2, -self._height/2.,
277 self._width*(3/4.)-2, self._height)
278 painter.setBrush(self._colors['decorations'])
279 painter.drawRoundedRect(text_box, 4, 4)
280 tag_text = "\n".join(self.commit.tags)
281 text_options.setAlignment(QtCore.Qt.AlignVCenter)
282 # A bit of padding for the text
283 painter.translate(2.,0.)
284 painter.drawText(text_box, tag_text, text_options)
286 def mousePressEvent(self, event):
287 self.selected = self.isSelected()
288 self.pressed = True
289 QtGui.QGraphicsItem.mousePressEvent(self, event)
291 def mouseMoveEvent(self, event):
292 if self.pressed:
293 self.dragged = True
294 QtGui.QGraphicsItem.mouseMoveEvent(self, event)
295 for node in self.scene().selectedItems():
296 for edge in node._edges:
297 edge.adjust()
298 self.scene().update()
300 def mouseReleaseEvent(self, event):
301 QtGui.QGraphicsItem.mouseReleaseEvent(self, event)
302 if (not self.dragged
303 and self.selected
304 and event.button() == QtCore.Qt.LeftButton):
305 self.setSelected(False)
306 self.skipped = True
307 return
308 self.skipped = False
309 self.pressed = False
310 self.dragged = False
313 class GraphView(QtGui.QGraphicsView):
314 def __init__(self):
315 QtGui.QGraphicsView.__init__(self)
317 self._xoff = 200
318 self._yoff = 42
319 self._xmax = 0
320 self._ymax = 0
322 self._items = []
323 self._selected = []
324 self._commits = {}
325 self._children = {}
326 self._nodes = {}
328 self._loc = {}
329 self._cols = {}
331 self._panning = False
332 self._last_mouse = [0, 0]
334 self._zoom = 1
335 self.scale(self._zoom, self._zoom)
336 self.setDragMode(self.RubberBandDrag)
338 size = 30000
339 scene = QtGui.QGraphicsScene(self)
340 scene.setItemIndexMethod(QtGui.QGraphicsScene.NoIndex)
341 scene.setSceneRect(-size/4, -size/2, size/2, size)
342 self.setScene(scene)
344 self.setCacheMode(QtGui.QGraphicsView.CacheBackground)
345 self.setRenderHint(QtGui.QPainter.Antialiasing)
346 self.setTransformationAnchor(QtGui.QGraphicsView.AnchorUnderMouse)
347 self.setResizeAnchor(QtGui.QGraphicsView.AnchorViewCenter)
348 self.setBackgroundColor()
350 def add_commits(self, commits):
351 """Traverse commits and add them to the view."""
352 self.add(commits)
353 self.layout(commits)
354 self.link(commits)
356 def keyPressEvent(self, event):
357 key = event.key()
359 if key == QtCore.Qt.Key_Plus:
360 self._scale_view(1.5)
361 elif key == QtCore.Qt.Key_Minus:
362 self._scale_view(1 / 1.5)
363 elif key == QtCore.Qt.Key_F:
364 self._view_fit()
365 elif event.key() == QtCore.Qt.Key_Z:
366 self._move_nodes_to_mouse_position()
367 else:
368 QtGui.QGraphicsView.keyPressEvent(self, event)
370 def _view_fit(self):
371 """Fit selected items into the viewport"""
373 items = self.scene().selectedItems()
374 if not items:
375 rect = self.scene().itemsBoundingRect()
376 else:
377 xmin = sys.maxint
378 ymin = sys.maxint
379 xmax = -sys.maxint
380 ymax = -sys.maxint
381 for item in items:
382 pos = item.pos()
383 item_rect = item.boundingRect()
384 xoff = item_rect.width()
385 yoff = item_rect.height()
386 xmin = min(xmin, pos.x())
387 ymin = min(ymin, pos.y())
388 xmax = max(xmax, pos.x()+xoff)
389 ymax = max(ymax, pos.y()+yoff)
390 rect = QtCore.QRectF(xmin, ymin, xmax-xmin, ymax-ymin)
391 adjust = 42.0
392 rect.setX(rect.x() - adjust)
393 rect.setY(rect.y() - adjust)
394 rect.setHeight(rect.height() + adjust)
395 rect.setWidth(rect.width() + adjust)
396 self.fitInView(rect, QtCore.Qt.KeepAspectRatio)
397 self.scene().invalidate()
399 def _save_selection(self, event):
400 if event.button() != QtCore.Qt.LeftButton:
401 return
402 elif QtCore.Qt.ShiftModifier != event.modifiers():
403 return
404 self._selected = [ i for i in self._items if i.isSelected() ]
406 def _restore_selection(self, event):
407 if QtCore.Qt.ShiftModifier != event.modifiers():
408 return
409 for item in self._selected:
410 if item.skipped:
411 item.skipped = False
412 continue
413 item.setSelected(True)
415 def _handle_event(self, eventhandler, event):
416 self.update()
417 self._save_selection(event)
418 eventhandler(self, event)
419 self._restore_selection(event)
421 def mousePressEvent(self, event):
422 if event.button() == QtCore.Qt.MidButton:
423 pos = event.pos()
424 self._mouse_start = [pos.x(), pos.y()]
425 self._saved_matrix = QtGui.QMatrix(self.matrix())
426 self._panning = True
427 return
428 self._handle_event(QtGui.QGraphicsView.mousePressEvent, event)
430 def mouseMoveEvent(self, event):
431 pos = self.mapToScene(event.pos())
432 if self._panning:
433 self._pan(event)
434 return
435 self._last_mouse[0] = pos.x()
436 self._last_mouse[1] = pos.y()
437 self._handle_event(QtGui.QGraphicsView.mouseMoveEvent, event)
439 def mouseReleaseEvent(self, event):
440 if event.button() == QtCore.Qt.MidButton:
441 self._panning = False
442 return
443 self._handle_event(QtGui.QGraphicsView.mouseReleaseEvent, event)
444 self._selected = []
446 def _pan(self, event):
447 pos = event.pos()
448 dx = pos.x() - self._mouse_start[0]
449 dy = pos.y() - self._mouse_start[1]
451 if dx == 0 and dy == 0:
452 return
454 rect = QtCore.QRect(0, 0, abs(dx), abs(dy))
455 delta = self.mapToScene(rect).boundingRect()
457 tx = delta.width()
458 if dx < 0.0:
459 tx = -tx
461 ty = delta.height()
462 if dy < 0.0:
463 ty = -ty
465 matrix = QtGui.QMatrix(self._saved_matrix).translate(tx, ty)
466 self.setTransformationAnchor(QtGui.QGraphicsView.NoAnchor)
467 self.setMatrix(matrix)
469 def wheelEvent(self, event):
470 """Handle Qt mouse wheel events."""
471 if event.modifiers() == QtCore.Qt.ControlModifier:
472 self._wheel_zoom(event)
473 else:
474 self._wheel_pan(event)
476 def _wheel_zoom(self, event):
477 """Handle mouse wheel zooming."""
478 zoom = math.pow(2.0, event.delta() / 512.0)
479 factor = (self.matrix()
480 .scale(zoom, zoom)
481 .mapRect(QtCore.QRectF(0.0, 0.0, 1.0, 1.0))
482 .width())
483 if factor < 0.014 or factor > 42.0:
484 return
485 self.setTransformationAnchor(QtGui.QGraphicsView.AnchorUnderMouse)
486 self._zoom = zoom
487 self.scale(zoom, zoom)
489 def _wheel_pan(self, event):
490 """Handle mouse wheel panning."""
492 if event.delta() < 0:
493 s = -100.0
494 else:
495 s = 100.0
497 pan_rect = QtCore.QRectF(0.0, 0.0, 1.0, 1.0)
498 factor = 1.0 / self.matrix().mapRect(pan_rect).width()
500 if event.orientation() == QtCore.Qt.Vertical:
501 matrix = self.matrix().translate(0, s * factor)
502 else:
503 matrix = self.matrix().translate(s * factor, 0)
505 self.setTransformationAnchor(QtGui.QGraphicsView.NoAnchor)
506 self.setMatrix(matrix)
508 def _move_nodes_to_mouse_position(self):
509 items = self.scene().selectedItems()
510 if not items:
511 return
512 dx = 0
513 dy = 0
514 min_distance = sys.maxint
515 for item in items:
516 width = item.boundingRect().width()
517 pos = item.pos()
518 tmp_dx = self._last_mouse[0] - pos.x() - width/2.0
519 tmp_dy = self._last_mouse[1] - pos.y() - width/2.0
520 distance = math.sqrt(tmp_dx ** 2 + tmp_dy ** 2)
521 if distance < min_distance:
522 min_distance = distance
523 dx = tmp_dx
524 dy = tmp_dy
525 for item in items:
526 pos = item.pos()
527 x = pos.x();
528 y = pos.y()
529 item.setPos( x + dx, y + dy )
531 def setBackgroundColor(self, color=None):
532 # To set a gradient background brush we need to use StretchToDeviceMode
533 # but that seems to be segfaulting. Use a solid background.
534 if not color:
535 color = QtGui.QColor(50,50,50)
536 self.setBackgroundBrush(color)
538 def _scale_view(self, scale):
539 factor = (self.matrix().scale(scale, scale)
540 .mapRect(QtCore.QRectF(0, 0, 1, 1))
541 .width())
542 if factor < 0.07 or factor > 100:
543 return
544 self._zoom = scale
545 self.scale(scale, scale)
547 def add(self, commits):
548 scene = self.scene()
549 for commit in commits:
550 self._commits[commit.sha1] = commit
551 for p in commit.parents:
552 children = self._children.setdefault(p, [])
553 children.append(commit.sha1)
554 node = Node(commit)
555 scene.addItem(node)
556 self._nodes[commit.sha1] = node
557 self._items.append(node)
559 def link(self, commits):
560 """Create edges linking commits with their parents"""
561 scene = self.scene()
562 for commit in commits:
563 children = self._children.get(commit.sha1, None)
564 # root commit
565 if children is None:
566 continue
567 commit_node = self._nodes[commit.sha1]
568 for child_sha1 in children:
569 child_node = self._nodes[child_sha1]
570 edge = Edge(commit_node, child_node)
571 scene.addItem(edge)
573 def layout(self, commits):
574 gxmax = self._xmax
575 gymax = self._ymax
577 xpos = 0
578 ypos = 0
579 for commit in commits:
580 if commit.sha1 not in self._children:
581 self._loc[commit.sha1] = (xpos, ypos)
582 node = self._nodes.get(commit.sha1, None)
583 node.setPos(xpos, ypos)
584 xpos += self._xoff
585 gxmax = max(xpos, gxmax)
586 continue
587 ymax = 0
588 xmax = None
589 for sha1 in self._children[commit.sha1]:
590 loc = self._loc[sha1]
591 if xmax is None:
592 xmax = loc[0]
593 xmax = min(xmax, loc[0])
594 ymax = max(ymax, loc[1])
595 gxmax = max(gxmax, xmax)
596 gymax = max(gymax, ymax)
597 if xmax is None:
598 xmax = 0
599 ymax += self._yoff
600 gymax = max(gymax, ymax)
601 if ymax in self._cols:
602 xmax = max(xmax, self._cols[ymax] + self._xoff)
603 gxmax = max(gxmax, xmax)
604 self._cols[ymax] = xmax
605 else:
606 xmax = max(0, xmax)
607 self._cols[ymax] = xmax
609 sha1 = commit.sha1
610 self._loc[sha1] = (xmax, ymax)
611 node = self._nodes[sha1]
612 node.setPos(xmax, ymax)
614 xpad = 200
615 ypad = 66
616 self._xmax = gxmax
617 self._ymax = gymax
618 self.scene().setSceneRect(-xpad, -ypad, gxmax+xpad, gymax+ypad)
620 if __name__ == "__main__":
621 app = QtGui.QApplication(sys.argv)
622 view = git_dag()
623 sys.exit(app.exec_())