4 @todo Handle sizing in a better manner http://www.gtkmm.org/docs/gtkmm-2.4/docs/tutorial/html/sec-custom-widgets.html
8 from __future__ import division
28 return (2 * math.pi * deg) / 360.0
32 return (360.0 * rad) / (2 * math.pi)
35 def normalize_radian_angle(radAng):
37 Restricts @param radAng to the range [0..2pi)
43 while twoPi <= radAng:
49 def delta_to_rtheta(dx, dy):
50 distance = math.sqrt(dx**2 + dy**2)
52 angleInRads = math.atan2(-dy, dx)
54 angleInRads = 2*math.pi + angleInRads
55 return distance, angleInRads
58 class FontCache(object):
63 def get_font(self, s):
64 if s in self.__fontCache:
65 return self.__fontCache[s]
67 descr = pango.FontDescription(s)
68 self.__fontCache[s] = descr
76 class ImageCache(object):
79 self.__imageCache = {}
81 os.path.join(os.path.dirname(__file__), "images"),
84 def add_path(self, path):
85 self.__imagePaths.append(path)
87 def get_image(self, s):
88 if s in self.__imageCache:
89 return self.__imageCache[s]
93 if s.lower().endswith(".png"):
94 for path in self.__imagePaths:
95 imagePath = os.path.join(path, s)
97 image = cairo.ImageSurface.create_from_png(imagePath)
100 warnings.warn("Unable to load image %s" % imagePath)
101 elif s.lower().endswith(".svg") and rsvg is not None:
102 for path in self.__imagePaths:
103 imagePath = os.path.join(path, s)
105 image = rsvg.Handle(file=imagePath)
107 warnings.warn("Unable to load image %s" % imagePath)
109 print "Don't know how to load image file type:", s
111 if image is not None:
112 self.__imageCache[s] = image
117 IMAGES = ImageCache()
120 def convert_color(gtkColor):
121 r = gtkColor.red / 65535
122 g = gtkColor.green / 65535
123 b = gtkColor.blue / 65535
127 def generate_pie_style(widget):
129 @bug This seems to always pick the same colors irregardless of the theme
132 # * gtk.STATE_NORMAL - The state of a sensitive widget that is not active and does not have the focus
133 # * gtk.STATE_ACTIVE - The state of a sensitive widget when it is active e.g. a button that is pressed but not yet released
134 # * gtk.STATE_PRELIGHT - The state of a sensitive widget that has the focus e.g. a button that has the mouse pointer over it.
135 # * gtk.STATE_SELECTED - The state of a widget that is selected e.g. selected text in a gtk.Entry widget
136 # * gtk.STATE_INSENSITIVE - The state of a widget that is insensitive and will not respond to any events e.g. cannot be activated, selected or prelit.
138 gtkStyle = widget.get_style()
141 "text": convert_color(gtkStyle.text[gtkStyleState]),
142 "fill": convert_color(gtkStyle.bg[gtkStyleState]),
145 for gtkStyleState in (
146 gtk.STATE_NORMAL, gtk.STATE_ACTIVE, gtk.STATE_PRELIGHT, gtk.STATE_SELECTED, gtk.STATE_INSENSITIVE
153 class PieSlice(object):
165 MAX_ANGULAR_SLICES = 8
179 SLICE_DIRECTION_NAMES = [
191 def __init__(self, handler = (lambda p, s, d: None)):
192 self._direction = self.SLICE_CENTER
195 self._handler = handler
197 def menu_init(self, pie, direction):
198 self._direction = direction
199 self._pie = weakref.ref(pie)
200 self._style = pie.sliceStyle
202 def calculate_minimum_radius(self, context, textLayout):
205 def draw_fg(self, styleState, isSelected, context, textLayout):
207 styleState = gtk.STATE_ACTIVE
208 self._draw_fg(styleState, context, textLayout)
210 def draw_bg(self, styleState, isSelected, context, textLayout):
212 styleState = gtk.STATE_ACTIVE
213 self._draw_bg(styleState, context, textLayout)
215 def _draw_fg(self, styleState, context, textLayout):
218 def _draw_bg(self, styleState, context, textLayout):
219 centerPosition = self._pie().centerPosition
220 radius = max(self._pie().radius, self.calculate_minimum_radius(context, textLayout))
221 outerRadius = self._pie().outerRadius
223 fillColor = self._style[styleState]["fill"]
227 if self._direction == self.SLICE_CENTER:
236 context.set_source_rgb(*fillColor)
239 sliceCenterAngle = self.quadrant_to_theta(self._direction)
240 sliceArcWidth = 2*math.pi / self.MAX_ANGULAR_SLICES
241 sliceStartAngle = sliceCenterAngle - sliceArcWidth/2
242 sliceEndAngle = sliceCenterAngle + sliceArcWidth/2
251 context.arc_negative(
260 context.set_source_rgb(*fillColor)
264 self._handler(self._pie(), self, self._direction)
267 def rtheta_to_quadrant(cls, distance, angleInRads, innerRadius):
268 if distance < innerRadius:
271 gradians = angleInRads / (2*math.pi)
272 preciseQuadrant = gradians * cls.MAX_ANGULAR_SLICES + cls.MAX_ANGULAR_SLICES / (2 * 2*math.pi)
273 quadrantWithWrap = int(preciseQuadrant)
274 quadrant = quadrantWithWrap % cls.MAX_ANGULAR_SLICES
280 def quadrant_to_theta(cls, quadrant):
284 gradians = quadrant / cls.MAX_ANGULAR_SLICES
285 radians = gradians * 2*math.pi
290 class NullPieSlice(PieSlice):
292 def draw_bg(self, styleState, isSelected, context, textLayout):
293 super(NullPieSlice, self).draw_bg(styleState, False, context, textLayout)
296 class LabelPieSlice(PieSlice):
298 def _align_label(self, labelWidth, labelHeight):
299 centerPosition = self._pie().centerPosition
300 if self._direction == PieSlice.SLICE_CENTER:
301 labelX = centerPosition[0] - labelWidth/2
302 labelY = centerPosition[1] - labelHeight/2
304 if self._direction in (PieSlice.SLICE_NORTH_WEST, PieSlice.SLICE_WEST, PieSlice.SLICE_SOUTH_WEST):
307 elif self._direction in (PieSlice.SLICE_SOUTH, PieSlice.SLICE_NORTH):
308 outerX = centerPosition[0]
309 labelX = outerX - labelWidth/2
310 elif self._direction in (PieSlice.SLICE_NORTH_EAST, PieSlice.SLICE_EAST, PieSlice.SLICE_SOUTH_EAST):
311 outerX = centerPosition[0] * 2
312 labelX = outerX - labelWidth
314 assert False, "Direction %d is incorrect" % self._direction
316 if self._direction in (PieSlice.SLICE_NORTH_EAST, PieSlice.SLICE_NORTH, PieSlice.SLICE_NORTH_WEST):
319 elif self._direction in (PieSlice.SLICE_EAST, PieSlice.SLICE_WEST):
320 outerY = centerPosition[1]
321 labelY = outerY - labelHeight/2
322 elif self._direction in (PieSlice.SLICE_SOUTH_EAST, PieSlice.SLICE_SOUTH, PieSlice.SLICE_SOUTH_WEST):
323 outerY = centerPosition[1] * 2
324 labelY = outerY - labelHeight
326 assert False, "Direction %d is incorrect" % self._direction
328 return int(labelX), int(labelY)
331 class TextLabelPieSlice(LabelPieSlice):
333 def __init__(self, text, fontName = 'Helvetica 12', handler = (lambda p, s, d: None)):
334 super(TextLabelPieSlice, self).__init__(handler = handler)
336 self.__fontName = fontName
338 def calculate_minimum_radius(self, context, textLayout):
339 font = FONTS.get_font(self.__fontName)
340 textLayout.set_font_description(font)
341 textLayout.set_markup(self.__text)
343 labelWidth, labelHeight = textLayout.get_pixel_size()
344 return min(labelWidth, labelHeight) / 2
346 def _draw_fg(self, styleState, context, textLayout):
347 super(TextLabelPieSlice, self)._draw_fg(styleState, context, textLayout)
349 textColor = self._style[styleState]["text"]
350 font = FONTS.get_font(self.__fontName)
352 context.set_source_rgb(*textColor)
353 textLayout.set_font_description(font)
354 textLayout.set_markup(self.__text)
355 labelWidth, labelHeight = textLayout.get_pixel_size()
356 labelX, labelY = self._align_label(labelWidth, labelHeight)
363 context.show_layout(textLayout)
366 class ImageLabelPieSlice(LabelPieSlice):
368 def __init__(self, imagePath, handler = (lambda p, s, d: None)):
369 super(ImageLabelPieSlice, self).__init__(handler = handler)
370 self.__imagePath = imagePath
372 def calculate_minimum_radius(self, context, textLayout):
373 image = IMAGES.get_image(self.__imagePath)
376 labelWidth, labelHeight = image.get_width(), image.get_height()
377 return min(labelWidth, labelHeight) / 2
379 def _draw_fg(self, styleState, context, textLayout):
380 super(ImageLabelPieSlice, self)._draw_fg(styleState, context, textLayout)
382 image = IMAGES.get_image(self.__imagePath)
386 labelWidth, labelHeight = image.get_width(), image.get_height()
387 labelX, labelY = self._align_label(labelWidth, labelHeight)
389 context.set_source_surface(
398 class PieMenu(gtk.DrawingArea):
400 def __init__(self, style = None, **kwds):
401 super(PieMenu, self).__init__()
403 self.sliceStyle = style
404 self.centerPosition = 0, 0
406 self.outerRadius = self.radius * 2
408 self.connect("expose_event", self._on_expose)
409 self.connect("motion_notify_event", self._on_motion_notify)
410 self.connect("leave_notify_event", self._on_leave_notify)
411 self.connect("proximity_in_event", self._on_motion_notify)
412 self.connect("proximity_out_event", self._on_leave_notify)
413 self.connect("button_press_event", self._on_button_press)
414 self.connect("button_release_event", self._on_button_release)
417 gtk.gdk.EXPOSURE_MASK |
418 gtk.gdk.POINTER_MOTION_MASK |
419 gtk.gdk.POINTER_MOTION_HINT_MASK |
420 gtk.gdk.BUTTON_MOTION_MASK |
421 gtk.gdk.BUTTON_PRESS_MASK |
422 gtk.gdk.BUTTON_RELEASE_MASK |
423 gtk.gdk.PROXIMITY_IN_MASK |
424 gtk.gdk.PROXIMITY_OUT_MASK |
425 gtk.gdk.LEAVE_NOTIFY_MASK
428 self.__activeSlice = None
430 for direction in PieSlice.SLICE_DIRECTIONS:
431 self.add_slice(NullPieSlice(), direction)
433 self.__clickPosition = 0, 0
434 self.__styleState = gtk.STATE_NORMAL
436 def add_slice(self, slice, direction):
437 assert direction in PieSlice.SLICE_DIRECTIONS
439 slice.menu_init(self, direction)
440 self.__slices[direction] = slice
442 if direction == PieSlice.SLICE_CENTER:
443 self.__activeSlice = self.__slices[PieSlice.SLICE_CENTER]
445 def __update_state(self, mousePosition):
446 rect = self.get_allocation()
447 newStyleState = self.__styleState
450 0 <= mousePosition[0] and mousePosition[1] < rect.width and
451 0 <= mousePosition[1] and mousePosition[1] < rect.height
453 if self.__clickPosition == (0, 0):
454 newStyleState = gtk.STATE_PRELIGHT
456 if self.__clickPosition != (0, 0):
457 newStyleState = gtk.STATE_PRELIGHT
459 if newStyleState != self.__styleState:
460 self.__generate_draw_event()
461 self.__styleState = newStyleState
463 def __process_mouse_position(self, mousePosition):
464 self.__update_state(mousePosition)
465 if self.__clickPosition == (0, 0):
469 mousePosition[0] - self.centerPosition[0],
470 - (mousePosition[1] - self.centerPosition[1])
472 distance, angleInRads = delta_to_rtheta(delta[0], delta[1])
473 quadrant = PieSlice.rtheta_to_quadrant(distance, angleInRads, self.radius)
474 self.__select_slice(self.__slices[quadrant])
476 def __select_slice(self, newSlice):
477 if newSlice is self.__activeSlice:
480 oldSlice = self.__activeSlice
481 self.__activeSlice = newSlice
482 self.__generate_draw_event()
484 def __generate_draw_event(self):
485 if self.window is None:
488 rect = self.get_allocation()
489 self.window.invalidate_rect(rect, True)
491 def _on_expose(self, widget, event):
492 # @bug Not getting all of the invalidation events needed on Hildon
493 # @bug Not highlighting proper slice besides center on Hildon
494 cairoContext = self.window.cairo_create()
495 pangoContext = self.create_pango_context()
496 textLayout = pango.Layout(pangoContext)
498 rect = self.get_allocation()
500 self.centerPosition = event.area.x + event.area.width / 2, event.area.y + event.area.height / 2
501 self.radius = max(rect.width, rect.width) / 3 / 2
502 self.outerRadius = max(rect.width, rect.height)
505 cairoContext.rectangle(
511 cairoContext.set_source_rgb(*self.sliceStyle[self.__styleState]["fill"])
514 isSelected = self.__clickPosition != (0, 0)
515 self.__activeSlice.draw_bg(self.__styleState, isSelected, cairoContext, textLayout)
518 for slice in self.__slices.itervalues():
519 isSelected = (slice is self.__activeSlice)
521 slice.draw_fg(self.__styleState, isSelected, cairoContext, textLayout)
523 isSelected = self.__clickPosition != (0, 0)
524 self.__activeSlice.draw_fg(self.__styleState, isSelected, cairoContext, textLayout)
526 def _on_leave_notify(self, widget, event):
527 newStyleState = gtk.STATE_NORMAL
528 if newStyleState != self.__styleState:
529 self.__generate_draw_event()
530 self.__styleState = newStyleState
532 mousePosition = event.get_coords()
533 self.__process_mouse_position(mousePosition)
535 def _on_motion_notify(self, widget, event):
536 mousePosition = event.get_coords()
537 self.__process_mouse_position(mousePosition)
539 def _on_button_press(self, widget, event):
540 self.__clickPosition = event.get_coords()
542 self._on_motion_notify(widget, event)
543 self.__generate_draw_event()
545 def _on_button_release(self, widget, event):
546 self._on_motion_notify(widget, event)
548 self.__activeSlice.activate()
549 self.__activeSlice = self.__slices[PieSlice.SLICE_CENTER]
550 self.__clickPosition = 0, 0
552 self.__generate_draw_event()
555 gobject.type_register(PieMenu)
558 class FakeEvent(object):
560 def __init__(self, x, y, isHint):
563 self.is_hint = isHint
565 def get_coords(self):
566 return self.x, self.y
569 class PiePopup(gtk.DrawingArea):
571 def __init__(self, style = None, **kwds):
572 super(PiePopup, self).__init__()
574 self.showAllSlices = True
575 self.sliceStyle = style
576 self.centerPosition = 0, 0
578 self.outerRadius = self.radius * 2
580 self.connect("expose_event", self._on_expose)
581 self.connect("motion_notify_event", self._on_motion_notify)
582 self.connect("proximity_in_event", self._on_motion_notify)
583 self.connect("proximity_out_event", self._on_leave_notify)
584 self.connect("leave_notify_event", self._on_leave_notify)
585 self.connect("button_press_event", self._on_button_press)
586 self.connect("button_release_event", self._on_button_release)
589 gtk.gdk.EXPOSURE_MASK |
590 gtk.gdk.POINTER_MOTION_MASK |
591 gtk.gdk.POINTER_MOTION_HINT_MASK |
592 gtk.gdk.BUTTON_MOTION_MASK |
593 gtk.gdk.BUTTON_PRESS_MASK |
594 gtk.gdk.BUTTON_RELEASE_MASK |
595 gtk.gdk.PROXIMITY_IN_MASK |
596 gtk.gdk.PROXIMITY_OUT_MASK |
597 gtk.gdk.LEAVE_NOTIFY_MASK
600 self.__popped = False
601 self.__styleState = gtk.STATE_NORMAL
602 self.__activeSlice = None
604 self.__localSlices = {}
606 self.__clickPosition = 0, 0
607 self.__popupTimeDelay = None
610 self.__pie = PieMenu(self.sliceStyle)
611 self.__pie.connect("button_release_event", self._on_button_release)
614 self.__popupWindow = gtk.Window(type = gtk.WINDOW_POPUP)
615 self.__popupWindow.set_title("")
616 self.__popupWindow.add(self.__pie)
618 for direction in PieSlice.SLICE_DIRECTIONS:
619 self.add_slice(NullPieSlice(), direction)
621 def add_slice(self, slice, direction):
622 assert direction in PieSlice.SLICE_DIRECTIONS
624 self.__slices[direction] = slice
625 self.__pie.add_slice(copy.copy(slice), direction)
627 if self.showAllSlices or direction == PieSlice.SLICE_CENTER:
628 self.__localSlices[direction] = copy.copy(slice)
629 self.__localSlices[direction].menu_init(self, direction)
630 if direction == PieSlice.SLICE_CENTER:
631 self.__activeSlice = self.__localSlices[PieSlice.SLICE_CENTER]
633 def __update_state(self, mousePosition):
634 rect = self.get_allocation()
635 newStyleState = self.__styleState
638 0 <= mousePosition[0] and mousePosition[0] < rect.width and
639 0 <= mousePosition[1] and mousePosition[1] < rect.height
641 if self.__clickPosition == (0, 0):
642 newStyleState = gtk.STATE_PRELIGHT
644 if self.__clickPosition != (0, 0):
645 newStyleState = gtk.STATE_PRELIGHT
647 if newStyleState != self.__styleState:
648 self.__styleState = newStyleState
649 self.__generate_draw_event()
651 def __generate_draw_event(self):
652 rect = self.get_allocation()
655 self.window.invalidate_rect(rect, True)
657 def _on_expose(self, widget, event):
658 cairoContext = self.window.cairo_create()
659 pangoContext = self.create_pango_context()
660 textLayout = pango.Layout(pangoContext)
662 rect = self.get_allocation()
664 self.centerPosition = event.area.x + event.area.width / 2, event.area.y + event.area.height / 2
665 self.radius = max(rect.width, rect.width) / 3 / 2
666 self.outerRadius = max(rect.width, rect.height)
669 cairoContext.rectangle(
675 cairoContext.set_source_rgb(*self.sliceStyle[self.__styleState]["fill"])
678 isSelected = self.__clickPosition != (0, 0)
679 self.__activeSlice.draw_bg(self.__styleState, isSelected, cairoContext, textLayout)
682 for slice in self.__localSlices.itervalues():
683 isSelected = (slice is self.__activeSlice)
685 slice.draw_fg(self.__styleState, isSelected, cairoContext, textLayout)
687 isSelected = self.__clickPosition != (0, 0)
688 self.__activeSlice.draw_fg(self.__styleState, isSelected, cairoContext, textLayout)
690 def _on_leave_notify(self, widget, event):
691 newStyleState = gtk.STATE_NORMAL
692 if newStyleState != self.__styleState:
693 self.__styleState = newStyleState
694 self.__generate_draw_event()
696 self._on_motion_notify(widget, event)
698 def _on_motion_notify(self, widget, event):
699 self.__update_state(event.get_coords())
700 if not self.__popped:
703 mousePosition = event.get_root_coords()
704 piePosition = self.__popupWindow.get_position()
705 event.x = mousePosition[0] - piePosition[0]
706 event.y = mousePosition[1] - piePosition[1]
707 self.__pie._on_motion_notify(self.__pie, event)
709 def _on_button_press(self, widget, event):
710 if len(self.__slices) == 0:
713 self.__clickPosition = event.get_root_coords()
714 self.__generate_draw_event()
715 self.__popupTimeDelay = gobject.timeout_add(100, self._on_delayed_popup)
717 def _on_delayed_popup(self):
718 self.__popup(self.__clickPosition)
719 gobject.source_remove(self.__popupTimeDelay)
720 self.__popupTimeDelay = None
723 def _on_button_release(self, widget, event):
724 if len(self.__slices) == 0:
727 if self.__popupTimeDelay is None:
728 mousePosition = event.get_root_coords()
729 piePosition = self.__popupWindow.get_position()
730 eventX = mousePosition[0] - piePosition[0]
731 eventY = mousePosition[1] - piePosition[1]
732 pieRelease = FakeEvent(eventX, eventY, False)
733 self.__pie._on_button_release(self.__pie, pieRelease)
737 gobject.source_remove(self.__popupTimeDelay)
738 self.__popupTimeDelay = None
739 self.__activeSlice.activate()
741 self.__clickPosition = 0, 0
742 self.__generate_draw_event()
744 def __popup(self, position):
745 assert not self.__popped
748 width, height = 256, 256
749 popupX, popupY = position[0] - width/2, position[1] - height/2
751 self.__popupWindow.move(int(popupX), int(popupY))
752 self.__popupWindow.resize(width, height)
753 pieClick = FakeEvent(width/2, height/2, False)
754 self.__pie._on_button_press(self.__pie, pieClick)
755 self.__pie.grab_focus()
757 self.__popupWindow.show()
761 self.__popped = False
763 piePosition = self.__popupWindow.get_position()
766 self.__popupWindow.hide()
769 gobject.type_register(PiePopup)
774 win.set_title("Pie Menu Test")
776 sliceStyle = generate_pie_style(win)
778 target = PiePopup(sliceStyle)
780 target = PieMenu(sliceStyle)
782 def handler(pie, slice, direction):
783 print pie, slice, direction
784 target.add_slice(TextLabelPieSlice("C", handler=handler), PieSlice.SLICE_CENTER)
785 target.add_slice(TextLabelPieSlice("N", handler=handler), PieSlice.SLICE_NORTH)
786 target.add_slice(TextLabelPieSlice("S", handler=handler), PieSlice.SLICE_SOUTH)
787 target.add_slice(TextLabelPieSlice("E", handler=handler), PieSlice.SLICE_EAST)
788 target.add_slice(TextLabelPieSlice("W", handler=handler), PieSlice.SLICE_WEST)
792 win.connect("destroy", lambda w: gtk.main_quit())
796 if __name__ == "__main__":