1
2# Copyright 2013-2020 Jaap Karssenberg <jaap.karssenberg@gmail.com>
3
4'''Action interface classes.
5
6Objects can have "actions", which are basically attributes of the
7class L{Action} or L{ToggleAction}. These objects are callable as bound
8methods. So actions are kind of special methods that define some
9interface parameters, like what icon and label to use in the menu.
10
11Use the L{action} and L{toggle_action} decorators to create actions.
12
13The classes defined here can cooperate with C{Gio.Action} to tie into the
14Gtk action framework. Also they can use Gtk widgets like C{Gtk.Button} as
15a "proxy" to trigger the action and reflect the state.
16
17## Menuhints
18
19The "menuhints" attribute for actions sets one or more hints of where the action
20should be in the menu and the behavior of the action. Multiple hints can
21be separated with ":" in the string. The first one determines the menu, other
22can modify the behavior.
23
24Known values include:
25
26  - notebook -- notebook section in "File" menu
27  - page -- page section in "File" menu
28  - edit -- "Edit" menu - modifies page, insensitive for read-only page
29  - insert -- "Insert" menu & editor actionbar - modifies page, insensitive for read-only page
30  - view -- "View" menu
31  - tools -- "Tools" menu - also shown in toolbar plugin if an icon is provided and tool and not the "headerbar" hint
32  - go -- "Go" menu
33  - accelonly -- do not show in menu, shortcut key only
34  - headerbar -- place action in the headerbar of the window, will place "view"
35    menu items on the right, others on the left
36  - toolbar -- used by toolbar plugin
37
38Other values are ignored silently
39
40 TODO: find right place in the documentation for this and update list
41
42'''
43
44import inspect
45import weakref
46import logging
47import re
48
49import zim.errors
50
51from zim.signals import SignalHandler
52
53
54logger = logging.getLogger('zim')
55
56try:
57	import gi
58	gi.require_version('Gtk', '3.0')
59	from gi.repository import Gtk
60	from gi.repository import Gio
61	from gi.repository import GLib
62except:
63	Gtk = None
64	Gio = None
65	GLib = None
66
67def _get_modifier_mask():
68	assert Gtk
69	x, mod = Gtk.accelerator_parse('<Primary>')
70	return mod
71
72PRIMARY_MODIFIER_STRING = '<Primary>'
73PRIMARY_MODIFIER_MASK = _get_modifier_mask()
74
75
76def hasaction(obj, actionname):
77	'''Like C{hasattr} but for attributes that define an action'''
78	actionname = actionname.replace('-', '_')
79	return hasattr(obj.__class__, actionname) \
80		and isinstance(getattr(obj.__class__, actionname), ActionDescriptor)
81
82
83class ActionDescriptor(object):
84
85	_bound_class = None
86
87	def __get__(self, instance, klass):
88		if instance is None:
89			return self # class access
90		else:
91			if instance not in self._bound_actions:
92				self._bound_actions[instance] = self._bound_class(instance, self)
93			return self._bound_actions[instance]
94
95
96def action(label, accelerator='', icon=None, verb_icon=None, menuhints='', alt_accelerator=None, tooltip=None):
97	'''Decorator to turn a method into an L{ActionMethod} object
98	Methods decorated with this decorator can have keyword arguments
99	but no positional arguments.
100	@param label: the label used e.g for the menu item (can use "_" for mnemonics)
101	@param accelerator: accelerator key description
102	@param icon: name of a "noun" icon - used together with the label. Only use
103	this for "things and places", not for actions or commands, and only if the
104	icon makes the item easier to recognize.
105	@param verb_icon: name of a "verb" icon - only used for compact menu views
106	@param menuhints: string with hints for menu placement and sensitivity
107	@param alt_accelerator: alternative accelerator key binding
108	@param tooltip: tooltip label, defaults to C{label}
109	'''
110	def _action(function):
111		return ActionClassMethod(function.__name__, function, label, icon, verb_icon, accelerator, alt_accelerator, menuhints, tooltip)
112
113	return _action
114
115
116class BoundActionMethod(object):
117
118	def __init__(self, instance, action):
119		self._instance = instance
120		self._action = action
121		self._sensitive = True
122		self._proxies = set()
123			# NOTE: Wanted to use WeakSet() here, but somehow we loose refs to
124			#       widgets still being displayed
125		self._gaction = None
126
127	def __call__(self, *args, **kwargs):
128		if not self._sensitive:
129			raise AssertionError('Action not senitive: %s' % self.name)
130		return self._action.func(self._instance, *args, **kwargs)
131
132	def __getattr__(self, name):
133		return getattr(self._action, name)
134
135	def get_sensitive(self):
136		return self._sensitive
137
138	def set_sensitive(self, sensitive):
139		self._sensitive = sensitive
140
141		if self._gaction:
142			self._gaction.set_enabled(sensitive)
143
144		for proxy in self._proxies:
145			proxy.set_sensitive(sensitive)
146
147	def get_gaction(self):
148		if self._gaction is None:
149			assert Gio is not None
150			self._gaction = Gio.SimpleAction.new(self.name)
151			self._gaction.set_enabled(self._sensitive)
152			self._gaction.connect('activate', self._on_activate)
153		return self._gaction
154
155	def _on_activate(self, proxy, value):
156		# "proxy" can either be Gtk.Button, Gtk.Action or Gio.Action
157		logger.debug('Action: %s', self.name)
158		try:
159			self.__call__()
160		except:
161			zim.errors.exception_handler(
162				'Exception during action: %s' % self.name)
163
164	def _connect_gtkaction(self, gtkaction):
165		gtkaction.connect('activate', self._on_activate_proxy)
166		gtkaction.set_sensitive(self._sensitive)
167		self._proxies.add(gtkaction)
168
169
170class ActionMethod(BoundActionMethod):
171
172	_button_class = Gtk.Button if Gtk is not None else None
173	_tool_button_class = Gtk.ToolButton if Gtk is not None else None
174
175	def create_button(self):
176		assert Gtk is not None
177		button = self._button_class.new_with_mnemonic(self.label)
178		button.set_tooltip_text(self.tooltip)
179		self.connect_button(button)
180		return button
181
182	def create_icon_button(self, fallback_icon=None):
183		assert Gtk is not None
184		icon_name = self.verb_icon or self.icon or fallback_icon
185		assert icon_name, 'No icon or verb_icon defined for action "%s"' % self.name
186		icon = Gtk.Image.new_from_icon_name(icon_name, Gtk.IconSize.BUTTON)
187		button = self._button_class()
188		button.set_image(icon)
189		button.set_tooltip_text(self.tooltip) # icon button should always have tooltip
190		self.connect_button(button)
191		return button
192
193	def create_tool_button(self, fallback_icon=None, connect_button=True):
194		assert Gtk is not None
195		icon_name = self.verb_icon or self.icon or fallback_icon
196		assert icon_name, 'No icon or verb_icon defined for action "%s"' % self.name
197		button = self._tool_button_class()
198		button.set_label(self.label)
199		button.set_use_underline(True)
200		button.set_icon_name(icon_name)
201		button.set_tooltip_text(self.tooltip) # icon button should always have tooltip
202		if connect_button:
203			self.connect_button(button)
204		return button
205
206	def connect_button(self, button):
207		button.connect('clicked', self._on_activate_proxy)
208		button.set_sensitive(self._sensitive)
209		self._proxies.add(button)
210		button.connect('destroy', self._on_destroy_proxy)
211
212	def _on_destroy_proxy(self, proxy):
213		self._proxies.discard(proxy)
214
215	def _on_activate_proxy(self, proxy):
216		self._on_activate(proxy, None)
217
218
219class ActionClassMethod(ActionDescriptor):
220
221	_bound_class = ActionMethod
222	_n_args = 1 # self
223
224	def __init__(self, name, func, label, icon=None, verb_icon=None, accelerator='', alt_accelerator=None, menuhints='', tooltip=None):
225		assert self._assert_args(func), '%s() has incompatible argspec' % func.__name__
226		tooltip = tooltip or label.replace('_', '')
227		self.name = name
228		self.func = func
229		self.label = label
230		self.tooltip = tooltip
231		self.icon = icon
232		self.verb_icon = verb_icon
233		self.hasicon = bool(self.verb_icon or self.icon)
234		self.menuhints = menuhints.split(':')
235
236		self._attr = (self.name, label, tooltip, icon or verb_icon)
237		self._alt_attr = (self.name + '_alt1', label, tooltip, icon or verb_icon)
238		self._accel = accelerator
239		self._alt_accel = alt_accelerator
240
241		self._bound_actions = weakref.WeakKeyDictionary()
242
243	def _assert_args(self, func):
244		spec = inspect.getfullargspec(func)
245		if spec.defaults:
246			return len(spec.defaults) == len(spec.args) - self._n_args
247		else:
248			return len(spec.args) == self._n_args
249
250
251def toggle_action(label, accelerator='', icon=None, verb_icon=None, init=False, menuhints='', tooltip=None):
252	'''Decorator to turn a method into an L{ToggleActionMethod} object
253
254	The decorated method should be defined as:
255	C{my_toggle_method(self, active)}. The 'C{active}' parameter is a
256	boolean that reflects the new state of the toggle.
257
258	Users can also call the method without setting the C{active}
259	parameter. In this case the wrapper determines how to toggle the
260	state and calls the inner function with the new state.
261
262	@param label: the label used e.g for the menu item (can use "_" for mnemonics)
263	@param accelerator: accelerator key description
264	@param icon: name of a "noun" icon - used together with the label. Only use
265	this for "things and places", not for actions or commands, and only if the
266	icon makes the item easier to recognize.
267	@param verb_icon: name of a "verb" icon - only used for compact menu views
268	@param init: initial state of the toggle
269	@param menuhints: string with hints for menu placement and sensitivity
270	@param tooltip: tooltip label, defaults to C{label}
271	'''
272	def _toggle_action(function):
273		return ToggleActionClassMethod(function.__name__, function, label, icon, verb_icon, accelerator, init, menuhints, tooltip)
274
275	return _toggle_action
276
277
278class ToggleActionMethod(ActionMethod):
279
280	_button_class = Gtk.ToggleButton if Gtk is not None else None
281	_tool_button_class = Gtk.ToggleToolButton if Gtk is not None else None
282
283	def __init__(self, instance, action):
284		ActionMethod.__init__(self, instance, action)
285		self._state = action._init
286
287	def __call__(self, active=None):
288		if not self._sensitive:
289			raise AssertionError('Action not senitive: %s' % self.name)
290
291		if active is None:
292			active = not self._state
293		elif active == self._state:
294			return # nothing to do
295
296		with self._on_activate.blocked():
297			self._action.func(self._instance, active)
298		self.set_active(active)
299
300	def create_tool_button(self, fallback_icon=None, connect_button=True):
301		if connect_button:
302			raise NotImplementedError # Should work but gives buggy behavior, try using gaction + set_action_name() instead
303		return ActionMethod.create_tool_button(self, fallback_icon, connect_button)
304
305	def connect_button(self, button):
306		'''Connect a C{Gtk.ToggleAction} or C{Gtk.ToggleButton} to this action'''
307		button.set_active(self._state)
308		button.set_sensitive(self._sensitive)
309		button.connect('toggled', self._on_activate_proxy)
310		self._proxies.add(button)
311
312	_connect_gtkaction = connect_button
313
314	def _on_activate_proxy(self, proxy):
315		self._on_activate(proxy, proxy.get_active())
316
317	@SignalHandler
318	def _on_activate(self, proxy, active):
319		'''Callback for activate signal of connected objects'''
320		if active != self._state:
321			logger.debug('Action: %s(%s)', self.name, active)
322			try:
323				self.__call__(active)
324			except Exception as error:
325				zim.errors.exception_handler(
326					'Exception during toggle action: %s(%s)' % (self.name, active))
327
328	def get_active(self):
329		return self._state
330
331	def set_active(self, active):
332		'''Change the state of the action without triggering the action'''
333		if active == self._state:
334			return
335		self._state = active
336
337		if self._gaction:
338			self._gaction.set_state(GLib.Variant.new_boolean(self._state))
339
340		with self._on_activate.blocked():
341			for proxy in self._proxies:
342				if isinstance(proxy, Gtk.ToggleToolButton):
343					pass
344				else:
345					proxy.set_active(active)
346
347	def get_gaction(self):
348		if self._gaction is None:
349			assert Gio is not None
350			self._gaction = Gio.SimpleAction.new_stateful(self.name, None, GLib.Variant.new_boolean(self._state))
351			self._gaction.set_enabled(self._sensitive)
352			self._gaction.connect('activate', self._on_activate)
353		return self._gaction
354
355
356class ToggleActionClassMethod(ActionClassMethod):
357	'''Toggle action, used by the L{toggle_action} decorator'''
358
359	_bound_class = ToggleActionMethod
360	_n_args = 2 # self, active
361
362	def __init__(self, name, func, label, icon=None, verb_icon=None, accelerator='', init=False, menuhints='', tooltip=None):
363		# The ToggleAction instance lives in the client class object;
364		# using weakkeydict to store instance attributes per
365		# client object
366		ActionClassMethod.__init__(self, name, func, label, icon, verb_icon, accelerator, menuhints=menuhints, tooltip=tooltip)
367		self._init = init
368
369	def _assert_args(self, func):
370		spec = inspect.getfullargspec(func)
371		return len(spec.args) == 2 # (self, active)
372
373
374def radio_action(menulabel, *radio_options, menuhints=''):
375	def _action(function):
376		return RadioActionClassMethod(function.__name__, function, menulabel, radio_options, menuhints)
377
378	return _action
379
380
381def radio_option(key, label, accelerator=''):
382	tooltip = label.replace('_', '')
383	return (key, None, label, accelerator, tooltip)
384		# tuple must match spec for actiongroup.add_radio_actions()
385
386
387def gtk_radioaction_set_current(g_radio_action, key):
388	# Gtk.radioaction.set_current is gtk >= 2.10
389	for a in g_radio_action.get_group():
390		if a.get_name().endswith('_' + key):
391			a.activate()
392			break
393
394
395class RadioActionMethod(BoundActionMethod):
396
397	def __init__(self, instance, action):
398		BoundActionMethod.__init__(self, instance, action)
399		self._state = None
400
401	def __call__(self, key):
402		if not key in self.keys:
403			raise ValueError('Invalid key: %s' % key)
404		self.func(self._instance, key)
405		self.set_state(key)
406
407	def get_state(self):
408		return self._state
409
410	def set_state(self, key):
411		self._state = key
412		for proxy in self._proxies:
413			gtk_radioaction_set_current(proxy, key)
414
415	def get_gaction(self):
416		raise NotImplementedError # TODO
417
418	def _connect_gtkaction(self, gtkaction):
419		gtkaction.connect('changed', self._on_gtkaction_changed)
420		self._proxies.add(gtkaction)
421		if self._state is not None:
422			gtk_radioaction_set_current(gtkaction, self._state)
423
424	def _on_gtkaction_changed(self, gaction, current):
425		try:
426			name = current.get_name()
427			assert name.startswith(self.name + '_')
428			key = name[len(self.name) + 1:]
429			if self._state == key:
430				pass
431			else:
432				logger.debug('Action: %s(%s)', self.name, key)
433				self.__call__(key)
434		except:
435			zim.errors.exception_handler(
436				'Exception during action: %s(%s)' % (self.name, key))
437
438
439class RadioActionClassMethod(ActionDescriptor):
440
441	_bound_class = RadioActionMethod
442
443	def __init__(self, name, func, menulabel, radio_options, menuhints=''):
444		self.name = name
445		self.func = func
446		self.menulabel = menulabel
447		self.keys = [opt[0] for opt in radio_options]
448		self._entries = tuple(
449			(name + '_' + opt[0],) + opt[1:] + (i,)
450				for i, opt in enumerate(radio_options)
451		)
452		self.hasicon = False
453		self.menuhints = menuhints.split(':')
454		self._bound_actions = weakref.WeakKeyDictionary()
455
456
457def get_actions(obj):
458	'''Returns bound actions for object
459
460	NOTE: See also L{zim.plugins.list_actions()} if you want to include actions
461	of plugin extensions
462	'''
463	actions = []
464	for name, action in inspect.getmembers(obj.__class__, lambda m: isinstance(m, ActionDescriptor)):
465		actions.append((name, action.__get__(obj, obj.__class__)))
466	return actions
467
468
469def get_gtk_actiongroup(obj):
470	'''Return a C{Gtk.ActionGroup} for an object using L{Action}
471	objects as attributes.
472
473	Defines the attribute C{obj.actiongroup} if it does not yet exist.
474
475	This method can only be used when gtk is available
476	'''
477	assert Gtk is not None
478
479	if hasattr(obj, 'actiongroup') \
480	and obj.actiongroup is not None:
481		return obj.actiongroup
482
483	obj.actiongroup = Gtk.ActionGroup(obj.__class__.__name__)
484
485	for name, action in get_actions(obj):
486		if isinstance(action, RadioActionMethod):
487			obj.actiongroup.add_radio_actions(action._entries)
488			gtkaction = obj.actiongroup.get_action(action._entries[0][0])
489			action._connect_gtkaction(gtkaction)
490		else:
491			_gtk_add_action_with_accel(obj, obj.actiongroup, action, action._attr, action._accel)
492			if action._alt_accel:
493				_gtk_add_action_with_accel(obj, obj.actiongroup, action, action._alt_attr, action._alt_accel)
494
495	return obj.actiongroup
496
497
498def _gtk_add_action_with_accel(obj, actiongroup, action, attr, accel):
499	assert Gtk is not None
500
501	if isinstance(action, ToggleActionMethod):
502		gtkaction = Gtk.ToggleAction(*attr)
503	else:
504		gtkaction = Gtk.Action(*attr)
505
506	gtkaction.zim_readonly = not bool(
507		'edit' in action.menuhints or 'insert' in action.menuhints
508	)
509	action._connect_gtkaction(gtkaction)
510	actiongroup.add_action_with_accel(gtkaction, accel)
511
512
513def initialize_actiongroup(obj, prefix):
514	assert Gio is not None
515	actiongroup = Gio.SimpleActionGroup()
516	for name, action in get_actions(obj):
517		gaction = action.get_gaction()
518		actiongroup.add_action(gaction)
519	obj.insert_action_group(prefix, actiongroup)
520