Source code for eye.helpers.actions

# this project is licensed under the WTFPLv2, see COPYING.txt for details

"""Module for registering actions on widgets

This module helps in the creation of `QAction` objects.
The `actions` module is to `QAction`s what the :any:`eye.connector` is to Qt signal and slots.

An action is registered with a name for a set of categories. The action can be triggered when a particular shortcut
is pressed, which are registered with :any:`register_action_shortcut`. When the action is triggered, in turn it can
trigger a callback (with :any:`register_action`) or a slot (with :any:`register_action_slot`, but this one is optional),
or a Scintilla editor action.

The use of categories lets the register be done once, not for every widget instance. Internally, `QAction` objects are
created automatically by the module in each instance.

For example, an action `print_console_file` could be created for editor widgets and bound to `Ctrl+P`::

	@register_action('editor', 'print_console_file')
	def my_action_func(ed):
		print(ed.text())

	register_action_shortcut('editor', 'print_console_file', 'Ctrl+P')

The same can be done in a single step::

	@register_shortcut('editor', 'Ctrl+P')
	def my_action_func(ed):
		print(ed.text())

This way is simpler but less re-usable because the action is unnamed. A plugin can register actions but should not
bind keyboard shortcuts to it and let user configuration do it.
"""

from collections import OrderedDict
from dataclasses import dataclass
from functools import wraps
from typing import Callable
import inspect
import logging

from PyQt5.Qsci import QsciCommand, QsciScintilla
from PyQt5.QtCore import Qt, QObject
from PyQt5.QtGui import QKeySequence
from PyQt5.QtWidgets import QAction

from eye import BUILDING_DOCS
from eye.connector import category_objects, CONNECTOR
from eye.qt import Slot

__all__ = (
	'register_action_shortcut', 'unregister_action_shortcut',
	'register_shortcut',
	'register_action_slot', 'register_action',
	'get_action'
)


LOGGER = logging.getLogger(__name__)


def setup_action_slot(obj, slot_name):
	"""Setup an object QAction triggering a slot, named after that slot

	The slot `slot_name` will be called when the `QAction` is triggered

	:param obj: the object in which to add the `QAction`
	:type obj: QObject
	:param slot_name: name of the slot and name of the action to add
	:type slot_name: str
	"""
	slot = getattr(obj, slot_name)
	build_action(obj, slot_name, slot)


def build_action(obj, action_name, target):
	"""Setup an object QAction triggering a callable

	A `QAction` will be created with name `action_name`, added as a child of `obj`.
	When the action's `triggered()` signal is emitted, the `target` will be called.

	See :any:`QObject.setObjectName`.

	:param obj: the object in which to add the QAction
	:type obj: QObject
	:param target: the function/method to call when action is triggered
	:type target: callable or Slot
	"""
	action = QAction(obj)
	action.setObjectName(action_name)
	obj.addAction(action)
	action.triggered.connect(target)

	return action


def disable_shortcut(obj, keyseq):
	"""Disable actions children of `obj` using shortcut `keyseq`

	If a children QAction of `obj` has a shortcut set to `keyseq`, the shortcut is disabled.
	This function will not work for internal Scintilla actions, see :any:`disableSciShortcut`.
	"""
	qkeyseq = QKeySequence(keyseq)

	for child in obj.children():
		if isinstance(child, QAction):
			if child.shortcut() == qkeyseq:
				child.setShortcut(QKeySequence())


def disable_sci_shortcut(obj, keyseq):
	"""Disable Scintilla action shortcuts

	If `obj` is an :any:`eye.widgets.editor.Editor` and `keyseq` is a shortcut triggering a Scintilla editor action,
	the shortcut is disabled.
	This function only works for internal editor actions, like `PyQt5.Qsci.QsciCommand.Undo`.

	See :any:`QsciCommand`. The reverse function is :any:`setSciShortcut`.
	"""
	assert keyseq.count() == 1
	qcmd = obj.standardCommands().boundTo(keyseq[0])
	if qcmd is not None:
		qcmd.setKey(0)


def set_sci_shortcut(obj, action_name, keyseq):
	"""Set shortcut for internal Scintilla editor action.

	If `obj` is an :any:`eye.widgets.editor.Editor`, the editor action `action_name` will be linked to keyboard
	shortcut `keyseq`.
	This function only works for internal editor actions, like `PyQt5.Qsci.QsciCommand.Undo`.

	See :any:`QsciCommand`.
	"""
	assert keyseq.count() == 1
	qval = getattr(QsciCommand, action_name)
	qcmd = obj.standardCommands().find(qval)
	qcmd.setKey(keyseq[0])


[docs] def get_action(obj, action_name): """Return children QAction of `obj` named `action_name`.""" return obj.findChild(QAction, action_name, Qt.FindDirectChildrenOnly)
def disable_action(obj, action_name): """Remove children QAction named""" action = get_action(obj, action_name) if action: obj.removeAction(action) # unparent action so it's not found as child anymore action.setParent(None)
[docs] def register_action_slot(categories, slot_name): """Register an action named `slot_name`, triggering slot `slot_name` An action named `slot_name` is registered for `categories`. When the action is triggered, it will call the slot `slot_name` of the object where the action is triggered. So, objects matching `categories` should have a slot `slot_name`. It's not required to call this function: when a keyboard shortcut is triggered, and the shortcut was bound to an action (with :any:`register_action_shortcut`) which had no callable registered (i.e. :any:`register_action` or similar functions were never called), then it tries to call a slot named the same as the action name. """ ACTIONS.register_action_slot(categories, slot_name)
def to_stringlist(obj): if isinstance(obj, (str, bytes)): return [obj] else: return obj @dataclass class CallbackAction: func: Callable caller: str class PlaceholderAction: pass class SlotAction: pass class CategoryStore(QObject): def __init__(self, **kwargs): super().__init__(**kwargs) self.by_cat = OrderedDict() self.by_key = OrderedDict() CONNECTOR.category_added.connect(self.category_added) CONNECTOR.category_removed.connect(self.category_removed) @Slot(object, str) def category_added(self, obj, cat): keys = self.by_cat.get(cat, {}) for key in keys: self.register_object(obj, key, keys[key]) @Slot(object, str) def category_removed(self, obj, cat): obj_cats = obj.categories() keys = self.by_cat.get(cat, {}) for key in keys: key_cats = set(self.by_key.get(key, [])) if not (obj_cats & key_cats): self.unregister_object(obj, key) def register_categories(self, categories, key, value): categories = set(to_stringlist(categories)) objects = set() for cat in categories: objects |= set(category_objects(cat)) for obj in objects: self.register_object(obj, key, value) for cat in categories: self.by_cat.setdefault(cat, OrderedDict())[key] = value self.by_key.setdefault(key, OrderedDict())[cat] = value def unregister_categories(self, categories, key): categories = set(to_stringlist(categories)) # categories left that will prevent an object from being unregistered key_cats = set(self.by_key.get(key, [])) retaining_cats = key_cats - categories # object list objects = set() for cat in categories: objects |= set(category_objects(cat)) for obj in objects: if not (obj.categories() & retaining_cats): self.unregister_object(obj, key) for cat in categories: try: del self.by_cat[cat][key] except KeyError: pass try: del self.by_key[key][cat] except KeyError: pass def register_object(self, obj, key, value): raise NotImplementedError() def unregister_object(self, obj, key): raise NotImplementedError() class ShortcutStore(CategoryStore): def __init__(self, **kwargs): super().__init__(**kwargs) def is_editor_command(self, obj, name): return isinstance(obj, QsciScintilla) and hasattr(QsciCommand, name) def register_object(self, obj, key, action_name): if self.is_editor_command(obj, action_name): disable_sci_shortcut(obj, key[0]) set_sci_shortcut(obj, action_name, key[0]) return disable_shortcut(obj, key[0]) action = get_action(obj, action_name) action.setShortcut(key[0]) action.setShortcutContext(key[1]) def unregister_object(self, obj, key): if isinstance(obj, QsciScintilla): disable_sci_shortcut(obj, key[0]) disable_shortcut(obj, key[0]) def register_shortcut(self, categories, key, value): LOGGER.info('registering shortcut %r with %r for categories %r', key, value, categories) self.register_categories(categories, key, value) def unregister_shortcut(self, categories, key): LOGGER.info('unregistering shortcut %r for categories %r', key, categories) self.unregister_categories(categories, key) class ActionStore(CategoryStore): def __init__(self, **kwargs): super().__init__(**kwargs) self.func_counter = 0 @Slot() def placeholder(self): LOGGER.warning("placeholder function shouldn't be called: %r", self.sender().objectName()) def has_slot(self, obj, slot_name): return callable(getattr(obj, slot_name, None)) def register_object(self, obj, key, value): LOGGER.debug('registering %s action %r for object %r', value, key, obj) old = get_action(obj, key) if old is not None: try: old.triggered.disconnect(self.placeholder) except TypeError: LOGGER.warning('will not override existing action %r from object %r', key, obj) return obj.removeAction(old) old.setParent(None) if isinstance(value, SlotAction) or self.has_slot(obj, key): setup_action_slot(obj, key) elif isinstance(value, CallbackAction): build_action(obj, key, value.func) elif isinstance(value, PlaceholderAction): build_action(obj, key, self.placeholder) def unregister_object(self, obj, key): LOGGER.debug('unregistering action %r for object %r', key, obj) disable_action(obj, key) def register_action_placeholder(self, categories, action_name): LOGGER.debug('creating registering placeholder action %r for categories %r', action_name, categories) self.register_categories(categories, action_name, PlaceholderAction()) def register_action_slot(self, categories, slot_name): LOGGER.info('registering slot action %r for categories %r', slot_name, categories) self.register_categories(categories, slot_name, SlotAction()) def register_action_func(self, categories, cb, name=None, caller=None): if name is None: self.func_counter += 1 name = '%s_%d' % (cb.__name__, self.func_counter) LOGGER.info('registering function action %r (name=%r) for categories %r', cb, name, categories) cb.action_name = name self.register_categories(categories, name, CallbackAction(cb, caller=caller)) return name def has_action(self, category, action_name): return action_name in self.by_cat.get(category, {})
[docs] def register_action(categories, action_name=None, stackoffset=0): """Decorate a function to be registered as an action The decorated function will be registered as action `action_name` for objects matching the `categories` """ if BUILDING_DOCS: return lambda x: x categories = set(to_stringlist(categories)) def decorator(cb): final_name = action_name or cb.__name__ caller = inspect.stack()[1 + stackoffset].filename @wraps(cb) def newcb(): return cb(SHORTCUTS.sender().parent()) ACTIONS.register_action_func(categories, newcb, name=final_name, caller=caller) return cb return decorator
[docs] def register_action_shortcut(categories, action_name, keyseq, context=Qt.WidgetShortcut): """Register a shortcut for an action :param categories: the categories of the widgets where to watch the shortcut :param action_name: the name of the action to trigger when the shortcut is triggered :type action_name: str :param keyseq: the shortcut description :type keyseq: str, int or QKeySequence :param context: the context where to listen to the shortcut, relative to the widgets matching the categories """ categories = set(to_stringlist(categories)) key = (QKeySequence(keyseq), context) create_ph = set() for cat in categories: if not ACTIONS.has_action(cat, action_name): create_ph.add(cat) if create_ph: ACTIONS.register_action_placeholder(create_ph, action_name) SHORTCUTS.register_shortcut(categories, key, action_name)
[docs] def unregister_action_shortcut(categories, keyseq, context=Qt.WidgetShortcut): """Unregister a keyboard shortcut previously registered After this call, current widgets matching `categories` will not have the keyboard shortcut anymore, and it won't be bound to new widgets matching `categories`. """ key = (QKeySequence(keyseq), context) SHORTCUTS.unregister_shortcut(categories, key)
[docs] def register_shortcut(categories, keyseq, context=Qt.WidgetShortcut, action_name=None): """Decorate a function to be called when a keyboard shortcut is typed When the keyboard shortcut `keyseq` is pressed in any widget matching `categories`, the decorated function will be called, with the widget passed as first parameter. Internally, when a widget matches the `categories`, a QAction is created for it and the shortcut is set. See :any:`build_action`. :param categories: the categories of the widgets where to watch the shortcut :type categories: str or list :param keyseq: the shortcut description :type keyseq: str, int or QKeySequence :param context: the context where to listen to the shortcut, relative to the widgets matching the categories """ if BUILDING_DOCS: return lambda x: x key = (QKeySequence(keyseq), context) def decorator(cb): name = action_name @wraps(cb) def newcb(): return cb(SHORTCUTS.sender().parent()) name = ACTIONS.register_action_func(categories, newcb, name=name) SHORTCUTS.register_shortcut(categories, key, name) return cb return decorator
# monkey-patch qkeysequence so it is hashable QKeySequence.__hash__ = lambda self: hash(self.toString()) QKeySequence.__repr__ = lambda self: '<QKeySequence key=%r>' % self.toString() # warning: order is important since shortcuts can create a slot-action ACTIONS = ActionStore() SHORTCUTS = ShortcutStore()