#!/usr/bin/env python


## CLI options parsing
from optparse import OptionParser
parser = OptionParser(
	usage='%prog [options]',
	description='Draw radial menu'
)
parser.add_option(
	'-c', '--config',
	metavar='PATH', action='store',
	type='str', dest='cfg',
	help='path to configuration file'
)
optz, argz = parser.parse_args()

import os, sys
if not optz.cfg: optz.cfg = os.path.splitext(sys.argv[0])[0] + os.extsep + 'cfg'


## Main imports
from math import sin, cos, asin, acos, atan2, pi, hypot, degrees as deg
import re, operator as op, itertools as it
from collections import namedtuple
from string import whitespace as spaces

import gtk,\
	cairo as c,\
	pango as p,\
	pangocairo as pc


## Misc helper defs
pi2 = 2*pi
spin = lambda x: abs(pi2 + x) % pi2 # remove full spins from angle
die = lambda *a: gtk.main_quit() # quit function

class Dot(namedtuple('Dot', 'x y')):
	__slots__ = ()
	def __new__(cls, x, y):
		scons = super(Dot, cls).__new__
		if not isinstance(x, Vec): return scons(cls, x, y) # x, y
		else: return scons(cls, x.dc(y)) # Vec, d

class Vec(namedtuple('Vector', 'x y')): # TODO: refactory as "rect->polar" translators generator
	__slots__ = ()
	dc = lambda s,d: (s.x*d, s.y*d) # coordinates of some distance along vector
	dot = lambda s,d: Dot(*s.dc(d)) # dot on d px along vector (same as Dot(dv, d))
	orth = lambda s: s._make((s.y, -s.x) if s.x*s.y > 0 else (-s.y, s.x)) # clockwise orthogonal vector

class Color(namedtuple('Color', 'R G B')):
	__slots__ = ()
	def __new__(cls, *argz):
		scons = super(Color, cls).__new__
		if len(argz) != 1: return scons(cls, *argz)
		else: # hex specification: (#)rgb, (#)rrggbb
			cs = str(argz[0]).lstrip('#')
			cl = len(cs)/3
			return scons(cls, *(int(cs[cl*(i-1):cl*i]*(2/cl), 16)/255.0 for i in range(1,4)))

def chain(*argz):
	for arg in argz:
		if isinstance(arg, (str, int, float, unicode)): yield arg
		else:
			for sub in arg: yield sub




## Core elements: Widget, Compositor, Petal
class GRad(object):
	'''G(TK)Rad menu widget'''

	exposed = None

	# Basics
	_window = None
	_center = None
	_width = _height = 0

	# Actual content
	_stuff = None # compositor instance, dunno how to call it ;)

	# XShape props
	_mask = None # GDKBitmap in shape of a window
	_mask_ctx = None # persistent cairo context for mask manipulations

	_tab = re.compile('^(\s+)[^\s].*$')
	_int = re.compile('^-?[\d]+$')
	_float = re.compile('^-?[\d.]+$')
	def _cfg_parser(self, fd, indent=None): # TODO: add param names translation before storage
		'''
		Even though config format is a subset of yaml, it shouldn't
			be parsed as such, because yaml dicts are unordered.
		Unlike yaml, this parser retains items' order.
		'''
		data = []
		while True:
			line = fd.readline()
			ls = line.strip(spaces)
			if not line: break
			elif not ls or ls.startswith('#'): continue
			if indent == None:
				try: indent = self._tab.match(line).group(1)
				except AttributeError: indent = ''
			if indent:
				if line.startswith(indent): line = line.lstrip(indent)
				else:
					fd.seek(-len(line), os.SEEK_CUR)
					return data
			try: var, val = ls.split(':', 1)
			except ValueError: raise ValueError, 'Erroneous line: %s'%repr(line)
			val = val.strip(spaces)
			if not val: val = self._cfg_parser(fd)
			elif self._int.match(val): val = int(val)
			elif self._float.match(val): val = float(val)
			data.append((var, val))
		return data

	def __init__(self):
		cfg = dict(self._cfg_parser(open(optz.cfg)))

		# High-level parameters
		#~ try: self._tilt = cfg.pop('tilt')
		#~ except KeyError: pass

		# Basic window props
		win = self._window = gtk.Window()
		scr = win.get_screen()
		display = scr.get_display()
		win.set_decorated(0)

		self._width = scr.get_width()
		self._height = scr.get_height()
		self._center = Dot(*display.get_pointer()[1:3])

		# Drawing props
		win.set_app_paintable(1)
		gtk.widget_set_default_colormap(
			scr.get_rgba_colormap()
			or scr.get_rgb_colormap()
		)

		# Actual content
		self._stuff = Compositor(cfg)

		# Window shape mask
		self._mask = gtk.gdk.Pixmap(None, self._width, self._height, 1)
		self._mask_ctx = self._mask.cairo_create()
		self._mask_ctx.translate(self._center.x, self._center.y)
		self._shape()

		# Events
		win.set_events(gtk.gdk.ALL_EVENTS_MASK)
		win.connect('expose_event', self.expose)
		win.connect('enter_notify_event', self.activate)
		win.connect('motion_notify_event', self.activate)
		win.connect('leave_notify_event', self.deactivate)
		win.connect('button_press_event', self.proceed)
		win.connect('focus_out_event', die)
		win.connect('key_press_event', die)
		win.connect('destroy', die)

		# Ta da!
		win.show()
		win.fullscreen()
		win.grab_focus()

	def _shape(self, level=None):
		if not level: # init (clear) mask
			self._mask_ctx.set_operator(c.OPERATOR_CLEAR)
			self._mask_ctx.paint()
		self._mask_ctx.set_operator(c.OPERATOR_OVER)
		self._stuff.expose(self._mask_ctx, level=level, alpha=0.5)
		self._window.shape_combine_mask(self._mask, 0, 0)

	def _ctx(self):
		ctx = self._window.window.cairo_create()
		ctx.translate(self._center.x, self._center.y)
		return ctx
	def _pos_update(func):
		def __pos_update(self, widget, event):
			self._stuff.update( # rectraingular->polar coordinates conversion
				# TODO: refactory "update" as descriptor w/ builtin translation
				(atan2( event.x - self._center.x, self._center.y - event.y )-pi/2)%pi2, # i
				hypot(self._center.x - event.x, self._center.y - event.y) # r
			)
			return func(self, widget, event)
		return __pos_update

	def expose(self, widget, event):
		self._stuff.expose(self._ctx())
		self.exposed = True

	@_pos_update
	def activate(self, widget, event):
		if not self.exposed: return
		self._stuff.activate(self._ctx())
	def deactivate(self, widget, event):
		if not self.exposed: return
		self._stuff.deactivate(self._ctx())

	@_pos_update
	def proceed(self, widget, event):
		cmd = self._stuff.proceed()
		if not isinstance(cmd, int):
			cmd = cmd.split(' ')
			os.execvp(cmd[0], cmd)
		else:
			self._stuff.expose(self._ctx())
			self._shape(level=cmd) # just extend mask, no need to rebuild




class Compositor(object):
	'''Petals composition manager'''
	# TODO:
	#	Refactory _petals (or self?) as (dict of (self-drawing?) lists) subtypes w/ one-op get (polar dot as index)
	#	or as a recursive, self-expanding / executing list - tree
	#	use window as a natural (and the only needed) compositor
	#	implement recursive items' linking (wtf4!?)
	#	cfg should be passed down thru hierarchy (w/ base_i increment?)

	_petals = dict()
	_cfg = dict()
	# Configurable parameters
	_len = 100
	_gap_i = 0
	_gap_r = 2 # 2*border

	def __init__(self, cfg):
		# Common petal parameters
		self._cfg = cfg
		self._cfg_set('font_size', obj=Petal)
		self._cfg_set('font_family', obj=Petal)
		self._cfg_set('petal_len', '_len')
		self._cfg_set('petal_gap_i', '_gap_i')
		#~ self._cfg_set('petal_gap_r', '_gap_r') # not implemented yet
		# Custom colors, if any
		try: colors = dict(cfg.pop('colors'))
		except KeyError: pass
		else:
			for cs in it.ifilter(lambda cs: cs.startswith(Petal._cm), Petal.__dict__):
				cs = cs[len(Petal._cm):]
				try: setattr(Petal, Petal._cm+cs, Color(colors[cs]))
				except KeyError: pass
		# Init first level
		self.levelup(cfg['menu'], cfg['base_r'], cfg['tilt'])

	def _cfg_set(self, cfg_attr, attr=None, obj=None, src=None):
		try: setattr(obj or self, attr or cfg_attr, (src or self._cfg)[cfg_attr])
		except KeyError: pass

	def levelup(self, cfg, r, i0=0):
		i0 = spin(i0)
		if r in self._petals:
			for i in it.ifilter(lambda i: i>r, self._petals.keys()): del self._petals[i] # drop all higher levels
			pe = False # partial expose flag, used in proceed method
		else: pe = True
		self._petals[r] = list()
		for item in cfg:
			petal = Petal(r, self._len, i0, item).off()
			i0 = petal.i1 + self._gap_i
			self._petals[r].append(petal)
		return pe

	def expose(self, ctx, level=None, **kwz):
		petals = self._petals.iteritems() if not level else ((level, self._petals[level]),)
		for level, petals in petals:
			for p in petals: p.expose(ctx, **kwz)

	_active = None
	# Active_pos handling
	_pos_i = _pos_r = None
	def update(self, i, r): # TODO: refactory as a descriptor w/ builtin "rect->polar" translation
		self._pos_i = i
		self._pos_r = r
	# Active_pos-item mapping
	_chk_arc = lambda s,x: x.in_arc(s._pos_i, s._gap_i/2) # check logic depends on cross-axis span
	_chk_depth = lambda s,x: s._pos_r > x-s._gap_r/2 and s._pos_r < x+s._len+s._gap_r/2
	def _get_active(self):
		level = it.ifilter(lambda x: self._chk_depth(x[0]), self._petals.iteritems()).next()[1]
		return it.ifilter(self._chk_arc, level).next()

	# Appearance change handlers
	def activate(self, ctx):
		if not self._active or not (self._chk_arc(self._active) and self._chk_depth(self._active.r0)):
			self.deactivate(ctx)
			self._active = self._get_active().on().expose(ctx)
	def deactivate(self, ctx):
		if self._active:
			self._active.off().expose(ctx)
			self._active = False

	# Functionality
	_atom = lambda s,x: not isinstance(x, list)
	def proceed(self):
		clicked = self._get_active() # rendering-independent get, 'cause it might lag
		if self._atom(clicked.item): return clicked.item # line to exec
		else: # sublevel
			pe = self.levelup(clicked.item, clicked.r1, clicked.i0)
			if pe: return clicked.r1 # partial expose (new level)
			else: return 0 # full exposition (complete redraw)




class Petal(object):
	'''Single petal (menuitem)'''

	# Public stuff
	active = False
	label = None # petal label
	item = None # submenu list or exec string
	# Background / geometry parameters (polar coordinates)
	i0 = 0 # arc angle, at which this petal starts
	i1 = 0
	r0 = 0 # inner radius for this level
	r1 = 0 # outer radius, to use as a base for next level
	co = None # center offset
	# Font specs, set externally for a class
	font_family = 'sans'
	font_size = 10

	_img = None # item background
	_label = None # item label (rendered on alpha)
	_ctx = None # petal outline

	# Colors. TODO: refactory as defaultdict w/ auto-construction, move to public props
	_cm = '_color_' # color specification prefix, used for cfg color overrides
	_color_active_1 = Color(0.55, 0.7, 0.6)
	_color_active_2 = Color(0, 0.35, 0)
	_color_inactive_1 = Color(0.55, 0.7, 0.6)
	_color_inactive_2 = Color(0, 0, 0.35)
	_color_text = Color(1, 0, 0)
	_color_border = Color(0.85, 0, 0)

	_ichk = op.and_ # operator, used to check i against bounds, depends on int(i1 / pi2)
	in_arc = lambda s,i,pad: s._ichk(i > s.i0 - pad, i < s.i1 + pad)

	def __init__(self, r0, len, i0, item):
		r1 = r0 + len # outer radius
		in_h = 1.25 * self.font_size # min length of chord, usually a font size, also used as label height
		out_h = 2 * in_h # so it won't be completely straight petals, but slightly expanded on the outer end
		arc0 = asin( (in_h/2) / r0 ) * 2 # inner arc (w/ chord len = height) angle
		arc1 = min( asin( (out_h/2) / r1 ) * 2, arc0 ) # outer arc angle
		arc1_offset = (arc0 - arc1) / 2 # two segments that are left out on the outer arc
		petal_start = i0 + arc1_offset # i of arc1 start
		petal_end = i0 + arc1_offset + arc1 # i of arc1 end

		# Public props
		self.r0 = r0
		self.r1 = r1
		self.i0 = i0
		i1 = self.i1 = i0 + arc0 # i1 shouldn't be trimmed, unlike self.i1
		if self.i1 > pi2: # crosses the zero
			self.i1 = spin(self.i1)
			self._ichk = op.or_
		self.label, self.item = item

		# Side vectors
		sv1 = Vec(cos(petal_start), sin(petal_start))
		sv2 = Vec(cos(petal_end), sin(petal_end))
		# Vertex dots
		v1, v2 = sv1.dot(r0), sv2.dot(r0) # r0 vertexes
		v3, v4 = sv1.dot(r1), sv2.dot(r1) # r1 vertexes

		## Canvas
		ditch = r0 / 3 # canvas border, so aa pixels and inner arc won't be cut off
		# Bounding box, used only for canvas' creation
		c1 = Dot(min(v1.x, v2.x, v3.x, v4.x), min(v1.y, v2.y, v3.y, v4.y))
		c2 = Dot(max(v1.x, v2.x, v3.x, v4.x), max(v1.y, v2.y, v3.y, v4.y))
		self.co = Dot(ditch/2 - c1.x, ditch/2 - c1.y) # new center
		w = int(abs(c2.x-c1.x) + ditch)
		h = int(abs(c2.y-c1.y) + ditch)
		# Actual init
		self._img = c.ImageSurface(c.FORMAT_ARGB32, w, h)
		ctx = self._ctx = c.Context(self._img)
		ctx.translate(self.co.x, self.co.y)

		## Outline
		# Draw inner arc
		ctx.move_to(v1.x, v1.y)
		ctx.arc(0, 0, r0, i0, i1)
		# Draw top/bottom borders and the outer arc
		ctx.line_to(v4.x, v4.y)
		ctx.arc_negative(0, 0, r1, petal_end, petal_start)
		ctx.close_path()

		## Fill patterns
		# Bissect angle/vector, also used for label transformation
		ba = i0 + arc0/2
		bv = Vec(cos(ba), sin(ba))
		bvg = bv.dc(r0) + bv.dc(r1)
		pat_gen = lambda: c.LinearGradient(*bvg)
		# Active pattern
		pat = self._pat_on = pat_gen()
		pat.add_color_stop_rgba(*chain(0,  self._color_active_1, 0))
		pat.add_color_stop_rgba(*chain(len,  self._color_active_2, 0.85))
		# Inactive pattern
		pat = self._pat_off = pat_gen()
		pat.add_color_stop_rgba(*chain(0,  self._color_inactive_1, 0))
		pat.add_color_stop_rgba(*chain(len,  self._color_inactive_2, 0.85))

		## Text
		# Font
		fd = p.FontDescription()
		fd.set_family(self.font_family)
		fd.set_absolute_size(self.font_size * p.SCALE)
		# Context / Layout
		pctx = pc.CairoContext(ctx)
		pl = pctx.create_layout()
		pl.set_font_description(fd)
		pl.set_text('  '+self.label)
		# Store
		self._pctx = pctx, pl, ba

	def on(self):
		self.active = True
		self._ctx.set_source_rgb(*self._color_active_1)
		self._ctx.fill_preserve() # opaque base
		self._ctx.set_source(self._pat_on)
		self._ctx.fill_preserve() # gradient overlay
		self._stroke(self._ctx) # stroke the border
		self._draw_label(self._ctx) # text overlay
		return self
	def off(self):
		self.active = False
		self._ctx.set_source_rgb(*self._color_inactive_1)
		self._ctx.fill_preserve() # opaque base
		self._ctx.set_source(self._pat_off)
		self._ctx.fill_preserve() # gradient overlay
		self._stroke(self._ctx) # stroke the border
		self._draw_label(self._ctx) # text overlay
		return self
	def _stroke(self, ctx):
		ctx.set_source_rgb(*self._color_border)
		ctx.set_line_width(1)
		ctx.stroke_preserve()
	def _draw_label(self, ctx):
		pctx, pl, i = self._pctx
		ctx.save()
		ctx.rotate(i)
		ctx.set_source_rgb(*self._color_text)
		pctx.show_layout(pl)
		ctx.restore()

	def expose(self, ctx, alpha=None):
		ctx.translate(-self.co.x, -self.co.y)
		ctx.set_source_surface(self._img)
		ctx.set_operator(c.OPERATOR_OVER)
		if alpha == None: ctx.paint()
		elif alpha: ctx.paint_with_alpha(alpha)
		ctx.translate(self.co.x, self.co.y)
		return self




## Initiator
if __name__ == '__main__':
	rad_menu = GRad()
	gtk.main()
