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