Attempting to seperate view, model, and control to allow future re-use
[ejpi] / src / libraries / qtpie.py
1 #!/usr/bin/env python
2
3 import math
4
5 from PyQt4 import QtGui
6 from PyQt4 import QtCore
7
8
9 class QActionPieItem(object):
10
11         def __init__(self, action, weight = 1):
12                 self._action = action
13                 self._weight = weight
14
15         def action(self):
16                 return self._action
17
18         def setWeight(self, weight):
19                 self._weight = weight
20
21         def weight(self):
22                 return self._weight
23
24         def setEnabled(self, enabled = True):
25                 self._action.setEnabled(enabled)
26
27         def isEnabled(self):
28                 return self._action.isEnabled()
29
30
31 class PieFiling(object):
32
33         INNER_RADIUS_DEFAULT = 24
34         OUTER_RADIUS_DEFAULT = 64
35
36         SELECTION_CENTER = -1
37         SELECTION_NONE = -2
38
39         NULL_CENTER = QtGui.QAction(None)
40
41         def __init__(self, centerPos):
42                 self._innerRadius = self.INNER_RADIUS_DEFAULT
43                 self._outerRadius = self.OUTER_RADIUS_DEFAULT
44                 self._children = []
45                 self._center = self.NULL_CENTER
46
47                 self._centerPos = centerPos
48
49         def insertItem(self, item, index = -1):
50                 self._children.insert(index, item)
51
52         def removeItemAt(self, index):
53                 item = self._children.pop(index)
54
55         def set_center(self, item):
56                 if item is None:
57                         item = self.NULL_CENTER
58                 self._center = item
59
60         def center(self):
61                 return self._center
62
63         def clear(self):
64                 del self._children[:]
65
66         def itemAt(self, index):
67                 return self._children[index]
68
69         def indexAt(self, point):
70                 return self._angle_to_index(self._angle_at(point))
71
72         def innerRadius(self):
73                 return self._innerRadius
74
75         def setInnerRadius(self, radius):
76                 self._innerRadius = radius
77
78         def setCenterPosition(self, centerPos):
79                 self._centerPos = centerPos
80
81         def outerRadius(self):
82                 return self._outerRadius
83
84         def setOuterRadius(self, radius):
85                 self._outerRadius = radius
86
87         def __iter__(self):
88                 return iter(self._children)
89
90         def __len__(self):
91                 return len(self._children)
92
93         def __getitem__(self, index):
94                 return self._children[index]
95
96         def _index_to_angle(self, index, isShifted):
97                 index = index % len(self._children)
98
99                 totalWeight = sum(child.weight() for child in self._children)
100                 if totalWeight == 0:
101                         totalWeight = 1
102                 baseAngle = (2 * math.pi) / totalWeight
103
104                 angle = math.pi / 2
105                 if isShifted:
106                         if self._children:
107                                 angle -= (self._children[0].weight() * baseAngle) / 2
108                         else:
109                                 angle -= baseAngle / 2
110                 while angle < 0:
111                         angle += 2*math.pi
112
113                 for i, child in enumerate(self._children):
114                         if index < i:
115                                 break
116                         angle += child.weight() * baseAngle
117                 while (2*math.pi) < angle:
118                         angle -= 2*math.pi
119
120                 return angle
121
122         def _angle_to_index(self, angle):
123                 numChildren = len(self._children)
124                 if numChildren == 0:
125                         return self.SELECTION_CENTER
126
127                 totalWeight = sum(child.weight() for child in self._children)
128                 if totalWeight == 0:
129                         totalWeight = 1
130                 baseAngle = (2 * math.pi) / totalWeight
131
132                 iterAngle = math.pi / 2 - (self.itemAt(0).weight() * baseAngle) / 2
133                 while iterAngle < 0:
134                         iterAngle += 2 * math.pi
135
136                 oldIterAngle = iterAngle
137                 for index, child in enumerate(self._children):
138                         iterAngle += child.weight() * baseAngle
139                         if oldIterAngle < angle and angle <= iterAngle:
140                                 return index - 1 if index != 0 else numChildren - 1
141                         elif oldIterAngle < (angle + 2*math.pi) and (angle + 2*math.pi <= iterAngle):
142                                 return index - 1 if index != 0 else numChildren - 1
143                         oldIterAngle = iterAngle
144
145         def _radius_at(self, pos):
146                 xDelta = pos.x() - self._centerPos.x()
147                 yDelta = pos.y() - self._centerPos.y()
148
149                 radius = math.sqrt(xDelta ** 2 + yDelta ** 2)
150                 return radius
151
152         def _angle_at(self, pos):
153                 xDelta = pos.x() - self._centerPos.x()
154                 yDelta = pos.y() - self._centerPos.y()
155
156                 radius = math.sqrt(xDelta ** 2 + yDelta ** 2)
157                 angle = math.acos(xDelta / radius)
158                 if 0 <= yDelta:
159                         angle = 2*math.pi - angle
160
161                 return angle
162
163
164 class PieArtist(object):
165
166         ICON_SIZE_DEFAULT = 32
167
168         def __init__(self, filing):
169                 self._filing = filing
170
171                 self._cachedOuterRadius = self._filing.outerRadius()
172                 self._cachedInnerRadius = self._filing.innerRadius()
173                 canvasSize = self._cachedOuterRadius * 2 + 1
174                 self._canvas = QtGui.QPixmap(canvasSize, canvasSize)
175                 self._mask = None
176                 self.palette = None
177
178         def sizeHint(self):
179                 diameter = self._cachedOuterRadius * 2 + 1
180                 return QtCore.QSize(diameter, diameter)
181
182         def show(self, palette):
183                 self.palette = palette
184
185                 if (
186                         self._cachedOuterRadius != self._filing.outerRadius() or
187                         self._cachedInnerRadius != self._filing.innerRadius()
188                 ):
189                         self._cachedOuterRadius = self._filing.outerRadius()
190                         self._cachedInnerRadius = self._filing.innerRadius()
191                         self._canvas = self._canvas.scaled(self.sizeHint())
192
193                 if self._mask is None:
194                         self._mask = QtGui.QBitmap(self._canvas.size())
195                         self._mask.fill(QtCore.Qt.color0)
196                         self._generate_mask(self._mask)
197                         self._canvas.setMask(self._mask)
198                 return self._mask
199
200         def hide(self):
201                 self.palette = None
202
203         def paint(self, selectionIndex):
204                 painter = QtGui.QPainter(self._canvas)
205                 painter.setRenderHint(QtGui.QPainter.Antialiasing, True)
206
207                 adjustmentRect = self._canvas.rect().adjusted(0, 0, -1, -1)
208
209                 numChildren = len(self._filing)
210                 if numChildren < 2:
211                         if selectionIndex == 0 and self._filing[0].isEnabled():
212                                 painter.setBrush(self.palette.highlight())
213                         else:
214                                 painter.setBrush(self.palette.background())
215
216                         painter.fillRect(self.rect(), painter.brush())
217                 else:
218                         for i in xrange(len(self._filing)):
219                                 self._paint_slice_background(painter, adjustmentRect, i, selectionIndex)
220
221                 self._paint_center_background(painter, adjustmentRect, selectionIndex)
222                 self._paint_center_foreground(painter, selectionIndex)
223
224                 for i in xrange(len(self._filing)):
225                         self._paint_slice_foreground(painter, i, selectionIndex)
226
227                 return self._canvas
228
229         def _generate_mask(self, mask):
230                 """
231                 Specifies on the mask the shape of the pie menu
232                 """
233                 painter = QtGui.QPainter(mask)
234                 painter.setPen(QtCore.Qt.color1)
235                 painter.setBrush(QtCore.Qt.color1)
236                 painter.drawEllipse(mask.rect().adjusted(0, 0, -1, -1))
237
238         def _paint_slice_background(self, painter, adjustmentRect, i, selectionIndex):
239                 if i == selectionIndex and self._filing[i].isEnabled():
240                         painter.setBrush(self.palette.highlight())
241                 else:
242                         painter.setBrush(self.palette.background())
243                 painter.setPen(self.palette.mid().color())
244
245                 a = self._filing._index_to_angle(i, True)
246                 b = self._filing._index_to_angle(i + 1, True)
247                 if b < a:
248                         b += 2*math.pi
249                 size = b - a
250                 if size < 0:
251                         size += 2*math.pi
252
253                 startAngleInDeg = (a * 360 * 16) / (2*math.pi)
254                 sizeInDeg = (size * 360 * 16) / (2*math.pi)
255                 painter.drawPie(adjustmentRect, int(startAngleInDeg), int(sizeInDeg))
256
257         def _paint_slice_foreground(self, painter, i, selectionIndex):
258                 child = self._filing[i]
259
260                 a = self._filing._index_to_angle(i, True)
261                 b = self._filing._index_to_angle(i + 1, True)
262                 if b < a:
263                         b += 2*math.pi
264                 middleAngle = (a + b) / 2
265                 averageRadius = (self._cachedInnerRadius + self._cachedOuterRadius) / 2
266
267                 sliceX = averageRadius * math.cos(middleAngle)
268                 sliceY = - averageRadius * math.sin(middleAngle)
269
270                 pieX = self._canvas.rect().center().x()
271                 pieY = self._canvas.rect().center().y()
272                 self._paint_label(
273                         painter, child.action(), i == selectionIndex, pieX+sliceX, pieY+sliceY
274                 )
275
276         def _paint_label(self, painter, action, isSelected, x, y):
277                 text = action.text()
278                 fontMetrics = painter.fontMetrics()
279                 if text:
280                         textBoundingRect = fontMetrics.boundingRect(text)
281                 else:
282                         textBoundingRect = QtCore.QRect()
283                 textWidth = textBoundingRect.width()
284                 textHeight = textBoundingRect.height()
285
286                 icon = action.icon().pixmap(
287                         QtCore.QSize(self.ICON_SIZE_DEFAULT, self.ICON_SIZE_DEFAULT),
288                         QtGui.QIcon.Normal,
289                         QtGui.QIcon.On,
290                 )
291                 averageWidth = (icon.width() + textWidth)/2
292                 if not icon.isNull():
293                         iconRect = QtCore.QRect(
294                                 x - averageWidth,
295                                 y - icon.height()/2,
296                                 icon.width(),
297                                 icon.height(),
298                         )
299
300                         painter.drawPixmap(iconRect, icon)
301
302                 if text:
303                         if isSelected:
304                                 if action.isEnabled():
305                                         pen = self.palette.highlightedText()
306                                         brush = self.palette.highlight()
307                                 else:
308                                         pen = self.palette.mid()
309                                         brush = self.palette.background()
310                         else:
311                                 if action.isEnabled():
312                                         pen = self.palette.text()
313                                 else:
314                                         pen = self.palette.mid()
315                                 brush = self.palette.background()
316
317                         leftX = x - averageWidth + icon.width()
318                         topY = y + textHeight/2
319                         painter.setPen(pen.color())
320                         painter.setBrush(brush)
321                         painter.drawText(leftX, topY, text)
322
323         def _paint_center_background(self, painter, adjustmentRect, selectionIndex):
324                 dark = self.palette.dark().color()
325                 light = self.palette.light().color()
326                 if selectionIndex == PieFiling.SELECTION_CENTER and self._filing.center().isEnabled():
327                         background = self.palette.highlight().color()
328                 else:
329                         background = self.palette.background().color()
330
331                 innerRadius = self._cachedInnerRadius
332                 innerRect = QtCore.QRect(
333                         adjustmentRect.center().x() - innerRadius,
334                         adjustmentRect.center().y() - innerRadius,
335                         innerRadius * 2 + 1,
336                         innerRadius * 2 + 1,
337                 )
338
339                 painter.setPen(QtCore.Qt.NoPen)
340                 painter.setBrush(background)
341                 painter.drawPie(innerRect, 0, 360 * 16)
342
343                 painter.setPen(QtGui.QPen(dark, 1))
344                 painter.setBrush(QtCore.Qt.NoBrush)
345                 painter.drawEllipse(innerRect)
346
347                 painter.setPen(QtGui.QPen(dark, 1))
348                 painter.setBrush(QtCore.Qt.NoBrush)
349                 painter.drawEllipse(adjustmentRect)
350
351                 r = QtCore.QRect(innerRect)
352                 innerRect.setLeft(r.center().x() + ((r.left() - r.center().x()) / 3) * 1)
353                 innerRect.setRight(r.center().x() + ((r.right() - r.center().x()) / 3) * 1)
354                 innerRect.setTop(r.center().y() + ((r.top() - r.center().y()) / 3) * 1)
355                 innerRect.setBottom(r.center().y() + ((r.bottom() - r.center().y()) / 3) * 1)
356
357         def _paint_center_foreground(self, painter, selectionIndex):
358                 pieX = self._canvas.rect().center().x()
359                 pieY = self._canvas.rect().center().y()
360
361                 x = pieX
362                 y = pieY
363
364                 self._paint_label(
365                         painter,
366                         self._filing.center().action(),
367                         selectionIndex == PieFiling.SELECTION_CENTER,
368                         x, y
369                 )
370
371
372 class QPieMenu(QtGui.QWidget):
373
374         activated = QtCore.pyqtSignal(int)
375         highlighted = QtCore.pyqtSignal(int)
376         canceled = QtCore.pyqtSignal()
377         aboutToShow = QtCore.pyqtSignal()
378         aboutToHide = QtCore.pyqtSignal()
379
380         def __init__(self, parent = None):
381                 QtGui.QWidget.__init__(self, parent)
382                 self._filing = PieFiling(self.rect().center())
383                 self._artist = PieArtist(self._filing)
384                 self._selectionIndex = PieFiling.SELECTION_NONE
385
386                 self._mouseButtonPressed = False
387                 self._mousePosition = ()
388
389         def popup(self, pos):
390                 index = self.indexAt(self.mapFromGlobal(QtGui.QCursor.pos()))
391                 self._mousePosition = pos
392                 self.show()
393
394         def insertItem(self, item, index = -1):
395                 self._filing.insertItem(item, index)
396                 self.update()
397
398         def removeItemAt(self, index):
399                 self._filing.removeItemAt(index)
400                 self.update()
401
402         def set_center(self, item):
403                 self._filing.set_center(item)
404
405         def clear(self):
406                 self._filing.clear()
407                 self.update()
408
409         def itemAt(self, index):
410                 return self._filing.itemAt(index)
411
412         def indexAt(self, point):
413                 return self._filing.indexAt(point)
414
415         def innerRadius(self):
416                 return self._filing.innerRadius()
417
418         def setInnerRadius(self, radius):
419                 self._filing.setInnerRadius(radius)
420                 self.update()
421
422         def outerRadius(self):
423                 return self._filing.outerRadius()
424
425         def setOuterRadius(self, radius):
426                 self._filing.setOuterRadius(radius)
427                 self.update()
428
429         def sizeHint(self):
430                 return self._artist.sizeHint()
431
432         def mousePressEvent(self, mouseEvent):
433                 lastSelection = self._selectionIndex
434
435                 lastMousePos = mouseEvent.pos()
436                 self._update_selection(lastMousePos)
437                 self._mouseButtonPressed = True
438                 self._mousePosition = lastMousePos
439
440                 if lastSelection != self._selectionIndex:
441                         self.highlighted.emit(self._selectionIndex)
442                         self.update()
443
444         def mouseMoveEvent(self, mouseEvent):
445                 lastSelection = self._selectionIndex
446
447                 lastMousePos = mouseEvent.pos()
448                 self._update_selection(lastMousePos)
449
450                 if lastSelection != self._selectionIndex:
451                         self.highlighted.emit(self._selectionIndex)
452                         self.update()
453
454         def mouseReleaseEvent(self, mouseEvent):
455                 lastSelection = self._selectionIndex
456
457                 lastMousePos = mouseEvent.pos()
458                 self._update_selection(lastMousePos)
459                 self._mouseButtonPressed = False
460                 self._mousePosition = ()
461
462                 self._activate_at(self._selectionIndex)
463                 self.update()
464
465         def showEvent(self, showEvent):
466                 self.aboutToShow.emit()
467
468                 self._filing.setCenterPosition(self.rect().center())
469                 mask = self._artist.show(self.palette())
470                 self.setMask(mask)
471
472                 lastMousePos = self.mapFromGlobal(QtGui.QCursor.pos())
473                 self._update_selection(lastMousePos)
474
475                 QtGui.QWidget.showEvent(self, showEvent)
476
477         def hideEvent(self, hideEvent):
478                 self.canceled.emit()
479                 self._artist.hide()
480                 self._selectionIndex = PieFiling.SELECTION_NONE
481                 QtGui.QWidget.hideEvent(self, hideEvent)
482
483         def paintEvent(self, paintEvent):
484                 canvas = self._artist.paint(self._selectionIndex)
485
486                 screen = QtGui.QPainter(self)
487                 screen.drawPixmap(QtCore.QPoint(0, 0), canvas)
488
489                 QtGui.QWidget.paintEvent(self, paintEvent)
490
491         def _select_at(self, index):
492                 self._selectionIndex = index
493
494                 numChildren = len(self._filing)
495                 loopDelta = max(numChildren, 1)
496                 while self._selectionIndex < 0:
497                         self._selectionIndex += loopDelta
498                 while numChildren <= self._selectionIndex:
499                         self._selectionIndex -= loopDelta
500
501         def _update_selection(self, lastMousePos):
502                 radius = self._filing._radius_at(lastMousePos)
503                 if radius < self._filing.innerRadius():
504                         self._selectionIndex = PieFiling.SELECTION_CENTER
505                 elif radius <= self._filing.outerRadius():
506                         self._select_at(self.indexAt(lastMousePos))
507                 else:
508                         self._selectionIndex = PieFiling.SELECTION_NONE
509
510         def _activate_at(self, index):
511                 if index == PieFiling.SELECTION_NONE:
512                         print "Nothing selected"
513                         return
514                 elif index == PieFiling.SELECTION_CENTER:
515                         child = self._filing.center()
516                 else:
517                         child = self.itemAt(index)
518                 if child.action().isEnabled():
519                         child.action().trigger()
520                 self.activated.emit(index)
521                 self.aboutToHide.emit()
522                 self.hide()
523
524         def _on_key_press(self, keyEvent):
525                 if keyEvent.key in [QtCore.Qt.Key_Right, QtCore.Qt.Key_Down, QtCore.Qt.Key_Tab]:
526                         self._select_at(self._selectionIndex + 1)
527                         self.update()
528                 elif keyEvent.key in [QtCore.Qt.Key_Left, QtCore.Qt.Key_Up, QtCore.Qt.Key_Backtab]:
529                         self._select_at(self._selectionIndex - 1)
530                         self.update()
531                 elif keyEvent.key in [QtCore.Qt.Key_Return, QtCore.Qt.Key_Enter, QtCore.Qt.Key_Space]:
532                         self._activate_at(self._selectionIndex)
533                 elif keyEvent.key in [QtCore.Qt.Key_Escape, QtCore.Qt.Key_Backspace]:
534                         self._activate_at(PieFiling.SELECTION_NONE)
535
536
537 def _print(msg):
538         print msg
539
540
541 def _on_about_to_hide(app):
542         app.exit()
543
544
545 if __name__ == "__main__":
546         app = QtGui.QApplication([])
547         PieFiling.NULL_CENTER.setEnabled(False)
548
549         if False:
550                 pie = QPieMenu()
551                 pie.show()
552
553         if False:
554                 singleAction = QtGui.QAction(None)
555                 singleAction.setText("Boo")
556                 singleItem = QActionPieItem(singleAction)
557                 spie = QPieMenu()
558                 spie.insertItem(singleItem)
559                 spie.show()
560
561         if False:
562                 oneAction = QtGui.QAction(None)
563                 oneAction.setText("Chew")
564                 oneItem = QActionPieItem(oneAction)
565                 twoAction = QtGui.QAction(None)
566                 twoAction.setText("Foo")
567                 twoItem = QActionPieItem(twoAction)
568                 iconTextAction = QtGui.QAction(None)
569                 iconTextAction.setText("Icon")
570                 iconTextAction.setIcon(QtGui.QIcon.fromTheme("gtk-close"))
571                 iconTextItem = QActionPieItem(iconTextAction)
572                 mpie = QPieMenu()
573                 mpie.insertItem(oneItem)
574                 mpie.insertItem(twoItem)
575                 mpie.insertItem(oneItem)
576                 mpie.insertItem(iconTextItem)
577                 mpie.show()
578
579         if True:
580                 oneAction = QtGui.QAction(None)
581                 oneAction.setText("Chew")
582                 oneAction.triggered.connect(lambda: _print("Chew"))
583                 oneItem = QActionPieItem(oneAction)
584                 twoAction = QtGui.QAction(None)
585                 twoAction.setText("Foo")
586                 twoAction.triggered.connect(lambda: _print("Foo"))
587                 twoItem = QActionPieItem(twoAction)
588                 iconAction = QtGui.QAction(None)
589                 iconAction.setIcon(QtGui.QIcon.fromTheme("gtk-open"))
590                 iconAction.triggered.connect(lambda: _print("Icon"))
591                 iconItem = QActionPieItem(iconAction)
592                 iconTextAction = QtGui.QAction(None)
593                 iconTextAction.setText("Icon")
594                 iconTextAction.setIcon(QtGui.QIcon.fromTheme("gtk-close"))
595                 iconTextAction.triggered.connect(lambda: _print("Icon and text"))
596                 iconTextItem = QActionPieItem(iconTextAction)
597                 mpie = QPieMenu()
598                 mpie.set_center(iconItem)
599                 mpie.insertItem(oneItem)
600                 mpie.insertItem(twoItem)
601                 mpie.insertItem(oneItem)
602                 mpie.insertItem(iconTextItem)
603                 mpie.show()
604                 mpie.aboutToHide.connect(lambda: _on_about_to_hide(app))
605
606         app.exec_()