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 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 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 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.
'''
# ----- 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 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 slider(self, parent, id=None, min=None, max=None):
'''slider, integer values, from min to max'''
[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 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_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