430 lines
15 KiB
Python
430 lines
15 KiB
Python
"""
|
|
Provides basic nested menus.
|
|
"""
|
|
import cairo
|
|
import math
|
|
|
|
import yutani
|
|
import text_region
|
|
import toaru_fonts
|
|
from icon_cache import get_icon
|
|
|
|
menu_windows = {}
|
|
|
|
def close_enough(msg):
|
|
return msg.command == yutani.MouseEvent.RAISE and \
|
|
math.sqrt((msg.new_x - msg.old_x) ** 2 + (msg.new_y - msg.old_y) ** 2) < 10
|
|
|
|
class MenuBarWidget(object):
|
|
"""Widget for display multiple menus."""
|
|
|
|
height = 24
|
|
hilight_gradient_top = (93/255,163/255,236/255)
|
|
hilight_gradient_bottom = (56/255,137/255,220/255)
|
|
|
|
def __init__(self, window, entries):
|
|
self.window = window
|
|
self.entries = entries
|
|
self.font = toaru_fonts.Font(toaru_fonts.FONT_SANS_SERIF,13,0xFFFFFFFF)
|
|
self.active_menu = None
|
|
self.active_entry = None
|
|
self.gradient = cairo.LinearGradient(0,0,0,self.height)
|
|
self.gradient.add_color_stop_rgba(0.0,*self.hilight_gradient_top,1.0)
|
|
self.gradient.add_color_stop_rgba(1.0,*self.hilight_gradient_bottom,1.0)
|
|
|
|
def draw(self, cr, x, y, width):
|
|
self.x = x
|
|
self.y = y
|
|
self.width = width
|
|
|
|
cr.save()
|
|
cr.set_source_rgb(59/255,59/255,59/255)
|
|
cr.rectangle(0,0,width,self.height)
|
|
cr.fill()
|
|
cr.restore()
|
|
|
|
menus = self.window.menus
|
|
|
|
x_, y_ = cr.user_to_device(x,y)
|
|
offset = 0
|
|
for e in self.entries:
|
|
title, _ = e
|
|
w = self.font.width(title) + 10
|
|
if self.active_menu in menus.values() and e == self.active_entry:
|
|
cr.rectangle(offset+2,0,w+2,self.height)
|
|
cr.set_source(self.gradient)
|
|
cr.fill()
|
|
tr = text_region.TextRegion(int(x_)+8+offset,int(y_)+2,w,self.height,self.font)
|
|
tr.set_one_line()
|
|
tr.set_text(title)
|
|
tr.draw(self.window)
|
|
offset += w
|
|
|
|
def mouse_event(self, msg, x, y):
|
|
offset = 0
|
|
for e in self.entries:
|
|
title, menu = e
|
|
w = self.font.width(title) + 10
|
|
if x >= offset and x < offset + w:
|
|
if msg.command == yutani.MouseEvent.CLICK or close_enough(msg):
|
|
menu = MenuWindow(menu,(self.window.x+self.window.decorator.left_width(self.window)+offset,self.window.y+self.window.decorator.top_height(self.window)+self.height),root=self.window)
|
|
self.active_menu = menu
|
|
self.active_entry = e
|
|
elif self.active_menu and self.active_menu in self.window.menus.values() and e != self.active_entry:
|
|
self.active_menu.definitely_close()
|
|
menu = MenuWindow(menu,(self.window.x+self.window.decorator.left_width(self.window)+offset,self.window.y+self.window.decorator.top_height(self.window)+self.height),root=self.window)
|
|
self.active_menu = menu
|
|
self.active_entry = e
|
|
self.window.draw()
|
|
break
|
|
offset += w
|
|
|
|
|
|
class MenuEntryAction(object):
|
|
"""Menu entry class for describing a menu entry with an action."""
|
|
|
|
# This should be calculated from the space necessary for the icon,
|
|
# but we're going to be lazy for now and just assume they're all this big.
|
|
height = 20
|
|
|
|
hilight_border_top = (54/255,128/255,205/255)
|
|
hilight_gradient_top = (93/255,163/255,236/255)
|
|
hilight_gradient_bottom = (56/255,137/255,220/55)
|
|
hilight_border_bottom = (47/255,106/255,167/255)
|
|
|
|
right_margin = 50
|
|
|
|
def __init__(self, title, icon, action=None, data=None, rich=False):
|
|
self.title = title
|
|
self.icon = get_icon(icon,16) if icon else None
|
|
self.action = action
|
|
self.data = data
|
|
self.font = toaru_fonts.Font(toaru_fonts.FONT_SANS_SERIF,13,0xFF000000)
|
|
self.font_hilight = toaru_fonts.Font(toaru_fonts.FONT_SANS_SERIF,13,0xFFFFFFFF)
|
|
self.rich = rich
|
|
|
|
self.width = self.font.width(self.title) + self.right_margin # Arbitrary bit of extra space.
|
|
# Fit width to hold title?
|
|
self.tr = text_region.TextRegion(0,0,self.width - 22, 20, self.font)
|
|
self.update_text()
|
|
self.hilight = False
|
|
self.window = None
|
|
self.gradient = cairo.LinearGradient(0,0,0,self.height-2)
|
|
self.gradient.add_color_stop_rgba(0.0,*self.hilight_gradient_top,1.0)
|
|
self.gradient.add_color_stop_rgba(1.0,*self.hilight_gradient_bottom,1.0)
|
|
|
|
def update_text(self):
|
|
if self.rich:
|
|
self.tr.set_richtext(self.title)
|
|
else:
|
|
self.tr.set_text(self.title)
|
|
self.width = self.font.width(self.title) + self.right_margin # Arbitrary bit of extra space.
|
|
if self.rich:
|
|
try:
|
|
self.width = self.tr.get_offset_at_index(-1)[1][1] + self.right_margin
|
|
except:
|
|
pass # uh, fallback to the original width
|
|
self.tr.resize(self.width - 22, 20)
|
|
|
|
def focus_enter(self,keyboard=False):
|
|
if self.window and self.window.child:
|
|
self.window.child.definitely_close()
|
|
self.tr.set_font(self.font_hilight)
|
|
self.update_text()
|
|
self.hilight = True
|
|
|
|
def focus_leave(self):
|
|
self.tr.set_font(self.font)
|
|
self.update_text()
|
|
self.hilight = False
|
|
|
|
def draw(self, window, offset, ctx):
|
|
# Here, offset = y offset, not x like in panel widgets
|
|
# eventually, this all needs to be made generic with containers and calculated window coordinates...
|
|
# but for now, as always, we're being lazy
|
|
self.window = window
|
|
self.offset = offset
|
|
if self.hilight:
|
|
ctx.rectangle(1,offset,window.width-2,1)
|
|
ctx.set_source_rgb(*self.hilight_border_top)
|
|
ctx.fill()
|
|
ctx.rectangle(1,offset+self.height-1,window.width-2,1)
|
|
ctx.set_source_rgb(*self.hilight_border_bottom)
|
|
ctx.fill()
|
|
ctx.save()
|
|
ctx.translate(0,offset+1)
|
|
ctx.rectangle(1,0,window.width-2,self.height-2)
|
|
ctx.set_source(self.gradient)
|
|
ctx.fill()
|
|
ctx.restore()
|
|
if self.icon:
|
|
ctx.save()
|
|
ctx.translate(4,offset+2)
|
|
if self.icon.get_width != 16:
|
|
ctx.scale(16/self.icon.get_width(),16/self.icon.get_width())
|
|
ctx.set_source_surface(self.icon,0,0)
|
|
ctx.paint()
|
|
ctx.restore()
|
|
self.tr.move(22,offset+2)
|
|
self.tr.draw(window)
|
|
|
|
def activate(self):
|
|
if self.action:
|
|
self.action(self.data) # Probably like launch_app("terminal")
|
|
self.window.root.hovered_menu = None
|
|
self.focus_leave()
|
|
m = [m for m in self.window.root.menus.values()]
|
|
for k in m:
|
|
k.definitely_close()
|
|
self.window.root.draw()
|
|
|
|
def close_enough(self, msg):
|
|
return close_enough(msg) and msg.old_y >= self.offset and msg.old_y < self.offset + self.height
|
|
|
|
def mouse_action(self, msg):
|
|
if msg.command == yutani.MouseEvent.CLICK or self.close_enough(msg):
|
|
self.activate()
|
|
|
|
return False
|
|
|
|
class MenuEntrySubmenu(MenuEntryAction):
|
|
"""A menu entry which opens a nested submenu."""
|
|
|
|
def __init__(self, title, entries, icon="folder"):
|
|
super(MenuEntrySubmenu,self).__init__(title,icon,None,None)
|
|
self.entries = entries
|
|
self.tick = get_icon('menu-tick', 16)
|
|
self.tick_hilight = cairo.ImageSurface(cairo.FORMAT_ARGB32, self.tick.get_width(), self.tick.get_height())
|
|
tmp = cairo.Context(self.tick_hilight)
|
|
tmp.set_operator(cairo.OPERATOR_SOURCE)
|
|
tmp.set_source_surface(self.tick,0,0)
|
|
tmp.paint()
|
|
tmp.set_operator(cairo.OPERATOR_ATOP)
|
|
tmp.rectangle(0,0,16,16)
|
|
tmp.set_source_rgb(1,1,1)
|
|
tmp.paint()
|
|
|
|
def draw(self, window, offset, ctx):
|
|
super(MenuEntrySubmenu,self).draw(window, offset, ctx)
|
|
ctx.save()
|
|
ctx.set_source_surface(self.tick if not self.hilight else self.tick_hilight,window.width-16, self.offset + 2)
|
|
ctx.paint()
|
|
ctx.restore()
|
|
|
|
def focus_enter(self,keyboard=False):
|
|
self.tr.set_font(self.font_hilight)
|
|
self.tr.set_text(self.title)
|
|
self.hilight = True
|
|
if not keyboard:
|
|
self.activate()
|
|
|
|
def activate(self):
|
|
if self.window:
|
|
menu = MenuWindow(self.entries, (self.window.x + self.window.width - 2, self.window.y + self.offset - self.window.top_height), self.window, root=self.window.root)
|
|
def mouse_action(self, msg):
|
|
return False
|
|
|
|
class MenuEntryDivider(object):
|
|
"""A visible separator between menu entries. Does nothing."""
|
|
|
|
height = 6
|
|
width = 0
|
|
|
|
def draw(self, window, offset, ctx):
|
|
self.window = window
|
|
ctx.rectangle(2,offset+3,window.width-4,1)
|
|
ctx.set_source_rgb(0.7,0.7,0.7)
|
|
ctx.fill()
|
|
ctx.rectangle(2,offset+4,window.width-5,1)
|
|
ctx.set_source_rgb(0.98,0.98,0.98)
|
|
ctx.fill()
|
|
|
|
def focus_enter(self,keyboard=False):
|
|
if self.window and self.window.child:
|
|
self.window.child.definitely_close()
|
|
pass
|
|
|
|
def focus_leave(self):
|
|
pass
|
|
|
|
def mouse_action(self,msg):
|
|
return False
|
|
|
|
class MenuWindow(yutani.Window):
|
|
"""Nested menu window."""
|
|
|
|
# These should be part of some theming functionality, but for now we'll
|
|
# just embed them here in the MenuWindow class.
|
|
top_height = 4
|
|
bottom_height = 4
|
|
base_background = (239/255,238/255,232/255)
|
|
base_border = (109/255,111/255,112/255)
|
|
|
|
def __init__(self, entries, origin=(0,0), parent=None, root=None):
|
|
self.parent = parent
|
|
if self.parent:
|
|
self.parent.child = self
|
|
self.entries = entries
|
|
required_width = max([e.width for e in entries])
|
|
required_height = sum([e.height for e in entries]) + self.top_height + self.bottom_height
|
|
flags = yutani.WindowFlag.FLAG_ALT_ANIMATION
|
|
super(MenuWindow, self).__init__(required_width,required_height,doublebuffer=True,flags=flags)
|
|
menu_windows[self.wid] = self
|
|
self.move(*origin)
|
|
self.focused_widget = None
|
|
self.child = None
|
|
self.closed = False
|
|
self.root = root
|
|
self.root.menus[self.wid] = self
|
|
self.draw()
|
|
|
|
def draw(self):
|
|
surface = self.get_cairo_surface()
|
|
ctx = cairo.Context(surface)
|
|
|
|
ctx.set_operator(cairo.OPERATOR_SOURCE)
|
|
ctx.rectangle(0,0,self.width,self.height)
|
|
ctx.set_line_width(2)
|
|
ctx.set_source_rgb(*self.base_background)
|
|
ctx.fill_preserve()
|
|
ctx.set_source_rgb(*self.base_border)
|
|
ctx.stroke()
|
|
ctx.set_operator(cairo.OPERATOR_OVER)
|
|
|
|
offset = self.top_height
|
|
for entry in self.entries:
|
|
entry.draw(self,offset,ctx)
|
|
offset += entry.height
|
|
|
|
|
|
self.flip()
|
|
|
|
def mouse_action(self, msg):
|
|
if msg.new_y < self.top_height or msg.new_y >= self.height - self.bottom_height or \
|
|
msg.new_x < 0 or msg.new_x >= self.width:
|
|
if self.focused_widget:
|
|
self.focused_widget.focus_leave()
|
|
self.focused_widget = None
|
|
self.draw()
|
|
return
|
|
# We must have focused something
|
|
x = (msg.new_y - self.top_height)
|
|
offset = 0
|
|
new_widget = None
|
|
for entry in self.entries:
|
|
if x >= offset and x < offset + entry.height:
|
|
new_widget = entry
|
|
break
|
|
offset += entry.height
|
|
|
|
redraw = False
|
|
if new_widget:
|
|
if self.focused_widget != new_widget:
|
|
if self.focused_widget:
|
|
self.focused_widget.focus_leave()
|
|
new_widget.focus_enter(keyboard=False)
|
|
self.focused_widget = new_widget
|
|
redraw = True
|
|
if new_widget.mouse_action(msg):
|
|
redraw = True
|
|
if redraw:
|
|
self.draw()
|
|
|
|
def has_eventual_child(self, child):
|
|
"""Does this menu have the given menu as a child, or a child of a child, etc.?"""
|
|
if child is self: return True
|
|
if not self.child: return False
|
|
return self.child.has_eventual_child(child)
|
|
|
|
def keyboard_event(self, msg):
|
|
"""Handle keyboard."""
|
|
if msg.event.action != yutani.KeyAction.ACTION_DOWN:
|
|
return
|
|
if msg.event.keycode == yutani.Keycode.ESCAPE:
|
|
self.root.hovered_menu = None
|
|
self.root.menus[msg.wid].leave_menu()
|
|
return
|
|
|
|
self.root.hovered_menu = self
|
|
|
|
if msg.event.keycode == yutani.Keycode.ARROW_DOWN:
|
|
if not self.focused_widget and self.entries:
|
|
self.focused_widget = self.entries[0]
|
|
self.focused_widget.focus_enter(keyboard=True)
|
|
self.draw()
|
|
return
|
|
i = 0
|
|
for entry in self.entries:
|
|
if entry == self.focused_widget:
|
|
break
|
|
i += 1
|
|
i += 1
|
|
if i >= len(self.entries):
|
|
i = 0
|
|
self.focused_widget.focus_leave()
|
|
self.focused_widget = self.entries[i]
|
|
self.focused_widget.focus_enter(keyboard=True)
|
|
self.draw()
|
|
|
|
if msg.event.keycode == yutani.Keycode.ARROW_UP:
|
|
if not self.focused_widget and self.entries:
|
|
self.focused_widget = self.entries[0]
|
|
self.focused_widget.focus_enter(keyboard=True)
|
|
self.draw()
|
|
return
|
|
i = 0
|
|
for entry in self.entries:
|
|
if entry == self.focused_widget:
|
|
break
|
|
i += 1
|
|
i -= 1
|
|
if i < 0:
|
|
i = len(self.entries)-1
|
|
self.focused_widget.focus_leave()
|
|
self.focused_widget = self.entries[i]
|
|
self.focused_widget.focus_enter(keyboard=True)
|
|
self.draw()
|
|
|
|
if msg.event.keycode == yutani.Keycode.ARROW_LEFT:
|
|
self.root.hovered_menu = self.parent
|
|
self.definitely_close()
|
|
return
|
|
|
|
if msg.event.keycode == yutani.Keycode.ARROW_RIGHT:
|
|
if self.focused_widget and isinstance(self.focused_widget, MenuEntrySubmenu):
|
|
self.focused_widget.activate()
|
|
|
|
if msg.event.key == b'\n':
|
|
if self.focused_widget:
|
|
self.focused_widget.activate()
|
|
|
|
def close(self):
|
|
if self.wid in menu_windows:
|
|
del menu_windows[self.wid]
|
|
super(MenuWindow,self).close()
|
|
|
|
def definitely_close(self):
|
|
"""Close this menu and all of its submenus."""
|
|
if self.child:
|
|
self.child.definitely_close()
|
|
self.child = None
|
|
if self.closed:
|
|
return
|
|
if self.focused_widget:
|
|
self.focused_widget.focus_leave()
|
|
self.closed = True
|
|
wid = self.wid
|
|
self.close()
|
|
del self.root.menus[wid]
|
|
|
|
def leave_menu(self):
|
|
"""Focus has left this menu. If it is not a parent of the currently active menu, close it."""
|
|
if self.has_eventual_child(self.root.hovered_menu):
|
|
pass
|
|
else:
|
|
m = [m for m in self.root.menus.values()]
|
|
for k in m:
|
|
if not self.root.hovered_menu or (k is not self.root.hovered_menu.child and not k.has_eventual_child(self.root.hovered_menu)):
|
|
k.definitely_close()
|
|
|