Source code for ascii_designer.toolkit

import logging
import re
from collections import namedtuple
import itertools as it
from . import list_model

L = lambda: logging.getLogger(__name__)

__all__ = [
    'set_toolkit',
    'get_toolkit',
    'ToolkitBase',
    'ListBinding',
    'auto_id',
    ]

_TOOLKIT_NAME  = 'qt'
_TOOLKIT_OPTIONS = {}

[docs]def set_toolkit(toolkit_name, toolkit_options=None): '''Set the toolkit to use and toolkit options. Toolkit name can be ``tk``, ``ttk``, ``qt``. ``toolkit_options`` is a dictionary of toolkit specific global settings like font size or theme. See :py:obj:`.ToolkitTk`, :py:obj:`ToolkitQt`. ''' toolkit_name = toolkit_name.lower() if toolkit_name not in 'tk ttk qt'.split(' '): raise ValueError('Unsupported toolkit "%s"'%toolkit_name) global _TOOLKIT_NAME global _TOOLKIT_OPTIONS _TOOLKIT_NAME = toolkit_name _TOOLKIT_OPTIONS = toolkit_options or {}
[docs]def get_toolkit(): '''Get toolkit instance as previously set.''' if _TOOLKIT_NAME in ('tk', 'ttk'): from .toolkit_tk import ToolkitTk return ToolkitTk(prefer_ttk=(_TOOLKIT_NAME=='ttk'), **_TOOLKIT_OPTIONS) elif _TOOLKIT_NAME == 'qt': from .toolkit_qt import ToolkitQt return ToolkitQt(**_TOOLKIT_OPTIONS)
_unique_id_dispenser = it.count() _re_whitelist = re.compile(r'[a-zA-Z0-9_]')
[docs]def auto_id(id, text=None, last_label_id=''): '''for missing id, calculate one from text.''' if id: return id.casefold() text = text or '' text = text.strip().casefold().replace(" ", "_") good_chars = [c for c in text if _re_whitelist.match(c)] id = ''.join(good_chars) if id[0:1].isnumeric(): id = 'x'+id if not id: id = last_label_id if not id: id = 'x'+str(next(_unique_id_dispenser)) return id
_re_maybe_id_text = r'(?:\s*(?P<id>[a-zA-Z0-9_]+)\s*\:)?\s*(?P<text>[^(]*?)?\s*' class TreelistColumn( namedtuple("TreelistColumnBase", "id text editable") ): """Tree/listview column definition""" def _split_columns(columns, translations, translation_prefix): """Convert treelist column string to list of TreelistColumn's.""" columns = columns or '' columns = [txt.strip() for txt in columns.split(',') if txt.strip()] def make_column(txt): editable = txt.endswith("_") if editable: txt = txt[:-1] id = auto_id('', txt) txt = translations.get(translation_prefix+id, txt) return TreelistColumn(id, txt, editable) return [ make_column(txt) for txt in columns ]
[docs]class ToolkitBase: # (name, regex, human-readable explanation) grammar = [ ('box', r'\<%s\>'%_re_maybe_id_text, '"<Text>"'), ('option', r'\((?P<checked> |x)\)\s+%s$'%_re_maybe_id_text, '"( ) text" or "(x) text"'), ('checkbox', r'\[(?P<checked> |x)\]\s+%s$'%_re_maybe_id_text, '"[ ] Text" or "[x] Text"'), ('slider', r'\[\s*(?P<id>[a-zA-Z0-9_]+)\s*\:\s*(?P<min>\d+)\s*\-\+\-\s*(?P<max>\d+)\s*\]', '[id: 0 -+- 100]'), ('multiline',r'\[%s__\s*\]'%_re_maybe_id_text, '"[Text__]"'), ('textbox',r'\[%s_\s*\]'%_re_maybe_id_text, '"[Text_]"'), ('treelist',r'\[\s*=%s(?:\((?P<columns>.*?)\))?\s*\]'%_re_maybe_id_text, '"[= Text]" or [= Text (column1, column2, ..)]'), ('combo',r'\[%s_\s*(?:\((?P<values>.*?)\))?\s+v\s*\]'%_re_maybe_id_text, '"[Text_ v]" or "[Text_ (val1, val2, ...) v]'), ('dropdown',r'\[%s(?:\((?P<values>.*?)\))?\s+v\s*\]'%_re_maybe_id_text, '"[Text v]" or "[Text (val1, val2, ...) v]'), ('button', r'\[%s\]'%_re_maybe_id_text, '"[Text]"'), ( 'label', r'''(?x) (?: # Optional prefix: \s*(?P<id>[a-zA-Z0-9_]+)\s*:(?=.+) # Identifier followed by : followed by something | \. # OR single . )? (?P<text>.*?)$ # Any text up to end of string ''', '"Text" or ".Text"' ), ] menu_grammar = [ ('sub', r'%s>'%_re_maybe_id_text, '"text >"'), ('command', r'''(?ix)\s* (?P<id>[a-zA-Z0-9_]+\s*\:)? (?P<text>[^#]+) (?:\#(?P<shortcut>[a-zA-Z0-9-]*))? ''', '"text :C-A-S-x"'), ] default_shortcuts = { 'new': 'C-N', 'open': 'C-O', 'save': 'C-S', 'undo': 'C-Z', 'redo': 'C-S-Z', 'cut': 'C-X', 'copy': 'C-C', 'paste': 'C-P', 'find': 'C-F', 'refresh': 'F5', } widget_classes = { "label": None, "box": None, "box_labeled": None, "option": None, "checkbox": None, "slider": None, "multiline": None, "textbox": None, "treelist": None, "treelist_editable": None, "combo": None, "dropdown": None, "button": None } """Actual class to use for each widget type. This allows you to override the actual widget with a custom subclass, if desired. Obviously, this should be done *before* building the frame, e.g. in ``__init__``. The replacement must have the same init signature as the original widget class. """ def __init__(self): # Make a local copy, so that mutating on an instance won't have global side effects. self.widget_classes = self.widget_classes.copy() self._last_label_id = ''
[docs] def root(self, title='Window', icon='', on_close=None): '''make a root (window) widget. Optionally you can give a close handler.'''
[docs] def parse(self, parent, text, translations=None, translation_prefix=""): '''Returns the widget id and widget generated from the textual definition. Autogenerates id: - If given, use it - else, try to use text (extract all ``a-z0-9_`` chars) - else, use 'x123' with 123 being a globally unique number For label type, id handling is special: - The label's id will be ``"label_"`` + id - The id will be remembered and used on the next widget, if it has no id. If nothing matched, return None, None. Supports automatic translation of widgets. - translations is a dict-like object. We will use ``.get(key, default)`` to retrieve values. - ``translation_prefix`` is a global prefix, so that you can use the same dict for multiple forms. - For each widget that has ``text``, we look up ``<translation_prefix><id>`` in the dict. If present, the content will be used instead of original ``text``. - For treelist, we will also look up ``<translation_prefix><id>.<column_id>`` and use it as column name, if found. ''' if translations is None: translations = {} mangled_text = text.replace("~", ' ').strip() for name, regex, _ in self.grammar: m = re.match(regex, mangled_text) if m: d = m.groupdict() # special treatment for box and label if name in ('box', 'label'): d['given_id'] = d['id'] d['id'] = auto_id(d['id'], d.get('text', ''), self._last_label_id) # Special treatment for label if name == 'label': self._last_label_id = d['id'] d['id'] = d.pop('given_id', '') or 'label_'+d['id'] else: self._last_label_id = '' # Special treatment for treelist if name == 'treelist': text = d.get('text', '').strip() editable = d['first_column_editable'] = text.endswith('_') if editable: d['text'] = text[:-1] prefix = translation_prefix + d["id"] + "." d['columns'] = _split_columns(d.get('columns', ''), translations, prefix) if 'text' in d: text = (d['text'] or '').strip() d['text'] = translations.get(translation_prefix+d['id'], text) L().debug('%r --> %s %r', text, name, d) widget = getattr(self, name)(parent, **d) if widget is None: widget = self.label(parent, text='<UNSUPPORTED>') #raise ValueError('This toolkit does not support %s widget type.'%name) return d['id'], widget raise ValueError('Could not convert widget: %r'%(text,))
[docs] def parse_menu(self, parent, menudef, handlers, translations=None, translation_prefix=""): '''Parse menu definition list and attach to the handlers. Translations work the same as for `.parse`.''' if translations is None: translations = {} menudef = menudef[:] while menudef: item = menudef.pop(0) for name, regex, _ in self.menu_grammar: m = re.match(regex, item) if m: d = m.groupdict() d['id'] = auto_id(d['id'], d.get('text', '')) if 'text' in d: text = (d['text'] or '').strip() print(translation_prefix+d["id"]) d['text'] = translations.get(translation_prefix+d['id'], text) L().debug('Menuentry %r --> %s %r', item, name, d) if name == 'sub': submenu = self.menu_sub(parent, **d) self.parse_menu(submenu, menudef.pop(0), handlers, translations, translation_prefix) elif name == 'command': if d['shortcut'] is None and d['id'] in self.default_shortcuts: d['shortcut'] = self.default_shortcuts[d['id']] self.menu_command(parent, handler=getattr(handlers, d['id']), **d) else: raise ValueError(item) break
[docs] def row_stretch(self, container, row, proportion): '''set the given row to stretch according to the proportion.'''
[docs] def col_stretch(self, container, col, proportion): '''set the given col to stretch according to the proportion.'''
[docs] def anchor(self, widget, left=True, right=True, top=True, bottom=True): '''anchor the widget. Depending on the anchors, widget will be left-, right-, center-aligned or stretched. '''
[docs] def place(self, widget, row=0, col=0, rowspan=1, colspan=1): '''place widget'''
[docs] def connect(self, widget, function): '''bind the widget's default event to function. Default event is: * click() for a button * value_changed(new_value) for value-type controls; usually fired after focus-lost or Return-press. '''
[docs] def getval(self, widget): '''get python-type value from widget. '''
[docs] def setval(self, widget, value): '''update the widget from given python-type value. value-setting must not interfere with, i.e. not happen when the user is editing the widget. '''
[docs] def show(self, frame): '''do what is necessary to make frame appear onscreen. This should start the event loop if necessary. '''
[docs] def close(self, frame): '''close the frame'''
# ----- widget generators ------
[docs] def box(self, parent, id=None, text='', given_id=''): '''An empty panel (frame, widget, however you call it) or group box that you can fill with own widgets. ``given_id`` is the user-given id value, as opposed to ``id`` (the autogenerated one). A Group box is created if text AND given_id are set. The virtual attribute value is the panel itself, or in case of groupbox the contained panel. '''
[docs] def label(self, parent, id=None, label_id=None, text=''): '''label'''
[docs] def button(self, parent, id=None, text=''): '''button'''
[docs] def textbox(self, parent, id=None, text=''): '''single-line text entry box'''
[docs] def multiline(self, parent, id=None, text=''): '''multiline text entry box'''
[docs] def treelist(self, parent, id=None, text='', columns=None, first_column_editable=False): '''treeview (also usable as plain list) Column is a list of column definitions: something with .id, .text, .editable attrs. '''
[docs] def dropdown(self, parent, id=None, text='', values=None): '''dropdown box; values is the raw string between the parens. Only preset choices allowed.'''
[docs] def combo(self, parent, id=None, text='', values=None): '''combo box; values is the raw string between the parens. Free-text allowed.'''
[docs] def option(self, parent, id=None, text='', checked=None): '''Option button. Prefix 'O' for unchecked, '0' for checked.'''
[docs] def checkbox(self, parent, id=None, text='', checked=None): '''Checkbox'''
[docs] def slider(self, parent, id=None, min=None, max=None): '''slider, integer values, from min to max'''
[docs] def menu_root(self, parent): '''Create menu object and set as parent's menu.'''
[docs] def menu_sub(self, parent, id, text): '''Append submenu labeled ``text`` to menu ``parent``.'''
[docs] def menu_command(self, parent, id, text, shortcut, handler): '''Append command labeled ``text`` to menu ``parent``. Handler: ``func() -> None``, is immediately connected. ``shortcut`` follows the syntax ``(modifier)-(key)``, where ``modifier`` is one or more of ``C``, ``S``, ``A`` for Ctrl, Shift, Alt respectively. '''
[docs]class ListBinding: '''Glue code to connect an ObsList to a GUI List widget. Takes care of: * Extracting column values from list items * Remembering/applying GUI-sorting * Mapping "model" events to GUI actions and vice-versa. Properties: keys (list of str) column keys list (ObsList) the bound list sort_key (str) column sorted by sort_ascending (bool) sort order sorted (bool) whether list is currently sorted *by one of the list columns*. Sorting the list with a key function ("Python way") resets ``sorted`` to ``False``. factory (function() -> Any) Factory function for new items (on add). Signature might change in future releases. I am not sure right now what parameters might be useful. Abstract base class. Override methods where noted. ''' def __init__(self, keys, **kwargs): super().__init__(**kwargs) self.keys = list(keys or []) self._sources = {k:k for k in self.keys} # set text source always self._sources.setdefault('', '') self.sort_key = '' self.sort_ascending = True self.sorted = False # Set a dummy list first self._list:list_model.ObsList = None self.list = list_model.ObsList(binding=self) def no_factory(): raise RuntimeError('In order to create items, you need to set a factory!') self.factory = no_factory @property def list(self) -> list_model.ObsList: return self._list @list.setter def list(self, val): if val is self._list: return self._set_list(val) def _set_list(self, val): '''Actual list setter. Extracted as method to allow subclassing.''' # detach old list if set l = self._list if l is not None: l.on_insert -= self.on_insert l.on_replace -= self.on_replace l.on_remove -= self.on_remove l.on_load_children -= self.on_load_children l.on_sort -= self.on_sort l.on_get_selection -= self.on_get_selection l.binding = None # attach new list # If the passed-in value is not an obs list, make a copy. Retain the old # ObsList's children source property if set. if not isinstance(val, list_model.ObsList): val = list_model.ObsList(val, binding=self, toolkit_parent_id='') # Copy children / has_children from previous list. # FIXME: This is super hacky, think about where children_source should go. if self._list is not None: val.children_source(self._list._children_source, self._list._has_children_source) l = self._list = val if l is not None: l.on_insert += self.on_insert l.on_replace += self.on_replace l.on_remove += self.on_remove l.on_load_children += self.on_load_children l.on_sort += self.on_sort l.on_get_selection += self.on_get_selection l.binding = self
[docs] def sources(self, _text=None, **kwargs): '''Alter the data binding for each column. Takes the column names as kwargs and the data source as value; which can be: * Empty string to retrieve str(obj) * String ``"name"`` to retrieve attribute ``name`` from the source object (on attribute error, try to get as item) * list of one item ``['something']`` to get item ``'something'`` (think of it as index without object) * Callable ``lambda obj: ..`` to do a custom computation. The ``_text`` argument, if given, is used to set the source for the "default" (anynomous-column) value. ''' for key in kwargs: if key not in self.keys: raise KeyError('No column "%s" exists'%key) if _text is not None: kwargs[''] = _text self._sources.update(kwargs)
[docs] def retrieve(self, item, column=''): return list_model.retrieve(item, self._sources[column])
[docs] def store(self, item, val, column=''): return list_model.store(item, val, self._sources[column])
[docs] def sort(self, key=None, ascending:bool=None, restore=False): '''Sort the list using one of the columns. ``key`` can be a string, refering to the particular column of the listview. Use Empty String to refer to the anonymous column. If ``key`` is a callable, the list is sorted in normal fashion. Instead of specifying ``key`` and ``ascending``, you can set ``restore=True`` to reuse the last applied sorting, if any. ''' if restore and key is None: key = self.sort_key if restore and ascending is None: ascending = self.sort_ascending if isinstance(key, str): keyfunc = lambda item: self.retrieve(item, key) info = { 'sort_ascending': self.sort_ascending, 'sort_key': self.sort_key } else: # just use passed-in keyfunc, assume that it #doesn't match a column. keyfunc = key info = {} self._list.sort( keyfunc, reverse=not ascending, info=info )
[docs] def on_insert(self, idx, item, toolkit_parent_id): '''ABSTRACT: Insert item in tree, return toolkit_id'''
[docs] def on_replace(self, iid, item): '''ABSTRACT: update GUI with changed item'''
[docs] def on_remove(self, iid): '''ABSTRACT: remove item in GUI'''
[docs] def on_load_children(self, children): '''ABSTRACT: insert children into GUI tree'''
[docs] def on_sort(self, sublist, info): '''reorder rows in GUI Base implementation remembers sort_key, sort_ascending entries from info, and sets ``sorted`` flag if both are there. Subclasses should call the base, and then update header formatting if appropriate. ''' key = info.get('sort_key', None) asc = info.get('sort_ascending', None) if key is not None and asc is not None: self.sorted = True self.sort_key = key self.sort_ascending = asc else: self.sorted = False self.sort_key = '' self.sort_ascending = True
[docs] def on_get_selection(self): '''ABSTRACT: query selected nodes from GUI'''