views.dag: Make mouseWheel panning faster
[git-cola.git] / cola / views / dag.py
blob9cc9b74e2fc2d126a0999e4f4ed7a4d879851e75
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 = -133.
494 else:
495 s = 133.
496 pan_rect = QtCore.QRectF(0.0, 0.0, 1.0, 1.0)
497 factor = 1.0 / self.matrix().mapRect(pan_rect).width()
499 if event.orientation() == QtCore.Qt.Vertical:
500 matrix = self.matrix().translate(0, s * factor)
501 else:
502 matrix = self.matrix().translate(s * factor, 0)
503 self.setTransformationAnchor(QtGui.QGraphicsView.NoAnchor)
504 self.setMatrix(matrix)
506 def _move_nodes_to_mouse_position(self):
507 items = self.scene().selectedItems()
508 if not items:
509 return
510 dx = 0
511 dy = 0
512 min_distance = sys.maxint
513 for item in items:
514 width = item.boundingRect().width()
515 pos = item.pos()
516 tmp_dx = self._last_mouse[0] - pos.x() - width/2.0
517 tmp_dy = self._last_mouse[1] - pos.y() - width/2.0
518 distance = math.sqrt(tmp_dx ** 2 + tmp_dy ** 2)
519 if distance < min_distance:
520 min_distance = distance
521 dx = tmp_dx
522 dy = tmp_dy
523 for item in items:
524 pos = item.pos()
525 x = pos.x();
526 y = pos.y()
527 item.setPos( x + dx, y + dy )
529 def setBackgroundColor(self, color=None):
530 # To set a gradient background brush we need to use StretchToDeviceMode
531 # but that seems to be segfaulting. Use a solid background.
532 if not color:
533 color = QtGui.QColor(50,50,50)
534 self.setBackgroundBrush(color)
536 def _scale_view(self, scale):
537 factor = (self.matrix().scale(scale, scale)
538 .mapRect(QtCore.QRectF(0, 0, 1, 1))
539 .width())
540 if factor < 0.07 or factor > 100:
541 return
542 self._zoom = scale
543 self.scale(scale, scale)
545 def add(self, commits):
546 scene = self.scene()
547 for commit in commits:
548 self._commits[commit.sha1] = commit
549 for p in commit.parents:
550 children = self._children.setdefault(p, [])
551 children.append(commit.sha1)
552 node = Node(commit)
553 scene.addItem(node)
554 self._nodes[commit.sha1] = node
555 self._items.append(node)
557 def link(self, commits):
558 """Create edges linking commits with their parents"""
559 scene = self.scene()
560 for commit in commits:
561 children = self._children.get(commit.sha1, None)
562 # root commit
563 if children is None:
564 continue
565 commit_node = self._nodes[commit.sha1]
566 for child_sha1 in children:
567 child_node = self._nodes[child_sha1]
568 edge = Edge(commit_node, child_node)
569 scene.addItem(edge)
571 def layout(self, commits):
572 gxmax = self._xmax
573 gymax = self._ymax
575 xpos = 0
576 ypos = 0
577 for commit in commits:
578 if commit.sha1 not in self._children:
579 self._loc[commit.sha1] = (xpos, ypos)
580 node = self._nodes.get(commit.sha1, None)
581 node.setPos(xpos, ypos)
582 xpos += self._xoff
583 gxmax = max(xpos, gxmax)
584 continue
585 ymax = 0
586 xmax = None
587 for sha1 in self._children[commit.sha1]:
588 loc = self._loc[sha1]
589 if xmax is None:
590 xmax = loc[0]
591 xmax = min(xmax, loc[0])
592 ymax = max(ymax, loc[1])
593 gxmax = max(gxmax, xmax)
594 gymax = max(gymax, ymax)
595 if xmax is None:
596 xmax = 0
597 ymax += self._yoff
598 gymax = max(gymax, ymax)
599 if ymax in self._cols:
600 xmax = max(xmax, self._cols[ymax] + self._xoff)
601 gxmax = max(gxmax, xmax)
602 self._cols[ymax] = xmax
603 else:
604 xmax = max(0, xmax)
605 self._cols[ymax] = xmax
607 sha1 = commit.sha1
608 self._loc[sha1] = (xmax, ymax)
609 node = self._nodes[sha1]
610 node.setPos(xmax, ymax)
612 xpad = 200
613 ypad = 66
614 self._xmax = gxmax
615 self._ymax = gymax
616 self.scene().setSceneRect(-xpad, -ypad, gxmax+xpad, gymax+ypad)
618 if __name__ == "__main__":
619 app = QtGui.QApplication(sys.argv)
620 view = git_dag()
621 sys.exit(app.exec_())