├── .gitignore ├── example ├── main.py ├── ExampleWindow.py ├── ExampleComponent.py ├── ExampleModel.py ├── ExampleWindow.xml └── ExampleWindow.yaml ├── tkpf ├── __init__.py ├── AutoProperty.py ├── OptionMenu.py ├── NumericEntry.py ├── Window.py ├── Notebook.py ├── ViewModel.py ├── Bindable.py ├── parser.py ├── Menu.py ├── Component.py ├── Binding.py └── Directive.py ├── setup.py ├── readme.md └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.py~ -------------------------------------------------------------------------------- /example/main.py: -------------------------------------------------------------------------------- 1 | from ExampleModel import ExampleModel 2 | from ExampleWindow import ExampleWindow 3 | from ExampleComponent import ExampleComponent # because it is registered on import 4 | 5 | ExampleWindow(ExampleModel()).show() 6 | -------------------------------------------------------------------------------- /example/ExampleWindow.py: -------------------------------------------------------------------------------- 1 | from tkinter import filedialog 2 | from tkpf import Window 3 | 4 | 5 | class ExampleWindow(Window): 6 | template_path = 'example/ExampleWindow.yaml' 7 | 8 | def file_open(self): 9 | filedialog.askopenfilename() 10 | 11 | def do_stuff(self): 12 | print('Stuff') 13 | -------------------------------------------------------------------------------- /tkpf/__init__.py: -------------------------------------------------------------------------------- 1 | from tkpf.Component import Component 2 | from tkpf.Window import Window 3 | from tkpf.AutoProperty import AutoProperty 4 | from tkpf.Bindable import Bindable 5 | from tkpf.Binding import Binding 6 | from tkpf.ViewModel import ViewModel 7 | from tkpf.NumericEntry import NumericEntry 8 | from tkpf.Menu import Menu 9 | from tkpf.Notebook import Notebook 10 | -------------------------------------------------------------------------------- /example/ExampleComponent.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from tkpf import Component, Directive 3 | 4 | 5 | class ExampleComponent(Component): 6 | template = '' 7 | 8 | def config(self, **kwargs): 9 | self.thelabel.config(text=kwargs['custom-text']) 10 | 11 | 12 | if sys.version_info < (3, 6): 13 | Directive.Registry.register(ExampleComponent) 14 | -------------------------------------------------------------------------------- /tkpf/AutoProperty.py: -------------------------------------------------------------------------------- 1 | class AutoProperty(property): 2 | def __init__(self, arg=str, name=None): 3 | self.name = name 4 | 5 | if isinstance(arg, type): 6 | self.dtype = arg 7 | self.default_value = None 8 | else: 9 | self.default_value = arg 10 | self.dtype = type(arg) 11 | 12 | def getter(this) -> self.dtype: 13 | return getattr(this, self.private_membername) 14 | 15 | def setter(this, val): 16 | setattr(this, self.private_membername, val) 17 | 18 | super().__init__(getter, setter) 19 | 20 | @property 21 | def private_membername(self): 22 | return '__' + self.name 23 | -------------------------------------------------------------------------------- /tkpf/OptionMenu.py: -------------------------------------------------------------------------------- 1 | from tkinter import ttk 2 | 3 | 4 | class OptionMenu(ttk.OptionMenu): 5 | """ The role of this OptionMenu subclass is just to standardize the API so that it can be used 6 | uniformly from other code. """ 7 | 8 | def __init__(self, parent, **kwargs): 9 | kw = {k: v for k, v in kwargs.items() if k not in {'name', 'model'}} 10 | super().__init__(parent, None, **kw) 11 | 12 | def config(self, values=list(), variable=None, **kwargs): 13 | if variable: 14 | self._variable = variable 15 | kwargs['textvariable'] = variable 16 | if values: 17 | self.set_menu(values[0], *values) 18 | super().config(**kwargs) 19 | -------------------------------------------------------------------------------- /example/ExampleModel.py: -------------------------------------------------------------------------------- 1 | from tkpf import ViewModel, AutoProperty, Bindable 2 | 3 | 4 | class ExampleModel(ViewModel): 5 | choice = Bindable(AutoProperty(int)) 6 | dropdown_options = Bindable(AutoProperty()) 7 | combobox_selected = Bindable(AutoProperty()) 8 | optionmenu_selected = Bindable(AutoProperty()) 9 | 10 | @Bindable 11 | @property 12 | def numeric_value(self) -> int: 13 | return self.__num 14 | 15 | @numeric_value.setter 16 | def numeric_value(self, val): 17 | self.__num = val 18 | 19 | def __init__(self): 20 | super().__init__() 21 | self.__num = 5 22 | self.dropdown_options = ('suboption1', 'suboption2', 'suboption3') 23 | 24 | -------------------------------------------------------------------------------- /tkpf/NumericEntry.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from tkinter import ttk 3 | 4 | 5 | class Action(Enum): 6 | DELETE = 0 7 | INSERT = 1 8 | 9 | 10 | class NumericEntry(ttk.Entry): 11 | def __init__(self, *args, datatype: type=int, **kwargs): 12 | self.datatype = datatype 13 | super().__init__(*args, **kwargs) 14 | self.config(validate='key', 15 | validatecommand=(self.register(self.on_validate), '%d', '%P')) 16 | 17 | def on_validate(self, action, text): 18 | if int(action) == Action.INSERT.value: 19 | try: 20 | self.datatype(text) 21 | return True 22 | except ValueError: 23 | return False 24 | else: 25 | return True 26 | -------------------------------------------------------------------------------- /tkpf/Window.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | from tkinter import ttk 3 | from tkpf import Component 4 | 5 | _windows = [] 6 | 7 | 8 | class Window(Component): 9 | @classmethod 10 | def new_window(cls): 11 | if _windows: 12 | window = tk.Toplevel() 13 | else: 14 | window = tk.Tk() 15 | window.style = ttk.Style() 16 | if window.style.theme_use() == 'default' and 'clam' in window.style.theme_names(): 17 | window.style.theme_use('clam') 18 | 19 | _windows.append(window) 20 | return window 21 | 22 | def __init__(self, model): 23 | super().__init__(self.new_window(), None, model) 24 | self.title = '' 25 | 26 | def show(self): 27 | self.parent_widget.wm_title(self.title) 28 | self.root_widget.mainloop() 29 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | name='tkpf', 5 | description=' A GUI library for python/tkinter with two-way data binding', 6 | author='Márton Marczell', 7 | url="https://github.com/marczellm/tkpf", 8 | version='0.0.1', 9 | packages=find_packages(), 10 | install_requires=['pyyaml'], 11 | long_description="""\ 12 | tkpf is a library for building Tkinter GUIs in a paradigm influenced by WPF (Windows Presentation Foundation) and Angular. 13 | 14 | Main features are: 15 | 16 | * Declarative view hierarchy and layout in XML or YAML 17 | * One-way and two-way data binding 18 | * Componentization support 19 | """, 20 | keywords="tkinter", 21 | license="LGPLv3", 22 | classifiers=[ 23 | 'Intended Audience :: Developers', 24 | 'License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)', 25 | 'Programming Language :: Python', 26 | 'Programming Language :: Python :: 3', 27 | ], 28 | ) 29 | -------------------------------------------------------------------------------- /tkpf/Notebook.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from tkinter import ttk 3 | 4 | from tkpf import Directive 5 | 6 | 7 | class Notebook(Directive.Structural): 8 | def create(self, parent): 9 | if parent.tk.call('tk', 'windowingsystem') == 'aqua': 10 | s = ttk.Style() 11 | s.configure('TNotebook.Tab', padding=(12, 8, 12, 0)) 12 | return ttk.Notebook(parent) 13 | 14 | def add_child(self, parent, classname, attrib, text=None): 15 | tab_args = {k[4:]: v for k, v in attrib.items() if k.startswith('tab-')} 16 | attrib = {k: v for k, v in attrib.items() if not k.startswith('tab-')} 17 | directive, widget = super().add_child(parent, classname, attrib, text) 18 | if parent is self.root_widget: 19 | self.root_widget.add(widget, **tab_args) 20 | return directive, widget 21 | 22 | @property 23 | def named_widgets(self): 24 | return self.parent_directive.named_widgets 25 | 26 | 27 | if sys.version_info < (3, 6): 28 | Directive.Registry.register(Notebook) 29 | -------------------------------------------------------------------------------- /tkpf/ViewModel.py: -------------------------------------------------------------------------------- 1 | from tkpf.AutoProperty import AutoProperty 2 | from tkpf.Bindable import Bindable 3 | 4 | 5 | class ViewModelMeta(type): 6 | def __new__(mcs, name, bases, namespace): 7 | for name, member in namespace.items(): 8 | if isinstance(member, AutoProperty): 9 | member.name = name 10 | elif isinstance(member, Bindable) and isinstance(member.wrapped_property, AutoProperty): 11 | member.wrapped_property.name = name 12 | return super().__new__(mcs, name, bases, namespace) 13 | 14 | 15 | class ViewModel(metaclass=ViewModelMeta): 16 | def __init__(self): 17 | super().__init__() 18 | 19 | for name, member in type(self).__dict__.items(): 20 | if isinstance(member, AutoProperty): 21 | member.fset(self, member.default_value or member.dtype()) 22 | elif isinstance(member, Bindable) and isinstance(member.wrapped_property, AutoProperty): 23 | member.fset(self, member.wrapped_property.default_value or member.dtype()) 24 | -------------------------------------------------------------------------------- /tkpf/Bindable.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | 4 | class Bindable(property): 5 | def __init__(self, *args): 6 | if len(args) == 1: 7 | wrapped_prop = args[0] 8 | super().__init__(wrapped_prop.fget, self.wrap_setter(wrapped_prop.fset), wrapped_prop.fdel) 9 | self.wrapped_property = wrapped_prop 10 | self.bindings = [] 11 | self.observers = [] 12 | self.dtype = typing.get_type_hints(wrapped_prop.fget)['return'] 13 | else: 14 | super().__init__(*args) 15 | 16 | def notify_bindings(self, val, this): 17 | for binding in self.bindings: 18 | binding.notify_to_view(val, this) 19 | for observer in self.observers: 20 | observer(val, this) 21 | 22 | def wrap_setter(self, fset): 23 | def wrapped_setter(this, val): 24 | fset(this, val) 25 | self.notify_bindings(val, this) 26 | return wrapped_setter 27 | 28 | def setter(self, fset): 29 | ret = super().setter(self.wrap_setter(fset)) 30 | ret.wrapped_property = self.wrapped_property 31 | ret.bindings = self.bindings 32 | ret.dtype = self.dtype 33 | return ret 34 | -------------------------------------------------------------------------------- /tkpf/parser.py: -------------------------------------------------------------------------------- 1 | import xml.etree.ElementTree as Xml 2 | 3 | 4 | class XmlWrapper: 5 | def __init__(self, node): 6 | self.node = node 7 | 8 | @property 9 | def name(self): 10 | return self.node.tag 11 | 12 | @property 13 | def text(self): 14 | return self.node.text 15 | 16 | @property 17 | def children(self): 18 | return (XmlWrapper(x) for x in self.node) 19 | 20 | @property 21 | def attrib(self): 22 | return self.node.attrib 23 | 24 | 25 | class DictWrapper: 26 | def __init__(self, name, dic): 27 | self.dic = dic or {} 28 | self.name = name 29 | self.text = None 30 | if 'children' not in self.dic: 31 | self.dic['children'] = [] 32 | 33 | @property 34 | def children(self): 35 | return (wrap(x) for x in self.dic['children']) 36 | 37 | @property 38 | def attrib(self): 39 | return {k: v for k, v in self.dic.items() if k != 'children'} 40 | 41 | 42 | def wrap(obj): 43 | if isinstance(obj, Xml.ElementTree): 44 | obj = obj.getroot() 45 | if isinstance(obj, Xml.Element): 46 | return XmlWrapper(obj) 47 | elif isinstance(obj, dict): 48 | if len(obj) != 1: 49 | raise Exception('Dict with keys {} does not specify a directive or widget'.format(list(obj.keys()))) 50 | k, v = next(iter(obj.items())) 51 | return DictWrapper(k, v) 52 | else: 53 | raise Exception('Object of type {} does not specify a directive or widget'.format(type(obj))) 54 | -------------------------------------------------------------------------------- /tkpf/Menu.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import functools 3 | import tkinter as tk 4 | from warnings import warn 5 | 6 | from tkpf import Directive 7 | 8 | 9 | class Menu(Directive.Structural): 10 | """ Translates the XML hierarchy into proper method calls when constructing a menu """ 11 | 12 | def create(self, parent): 13 | menu = tk.Menu(parent) 14 | if not isinstance(parent, tk.Menu): 15 | parent.winfo_toplevel().config(menu=menu) 16 | return menu 17 | 18 | def add_child(self, parent, classname, attrib, text=None): 19 | name = attrib.pop('name', None) 20 | if text: 21 | attrib['label'] = text 22 | if classname == 'Menu': 23 | directive, widget = super().inflate(parent, classname) 24 | widget.config(tearoff=0) 25 | self.root_widget.add_cascade(menu=widget, **self.resolve_bindings(widget, attrib)) 26 | self.named_widgets[name] = directive or widget 27 | return directive, widget 28 | else: 29 | last = 1 + (self.root_widget.index('end') or 0) 30 | pseudo_name = str(self.root_widget) + '.Tkpf_menuitem:' + str(last) 31 | config_method = functools.partial(self.root_widget.entryconfig, last) 32 | self.root_widget.add(classname.lower(), **self.resolve_bindings(None, attrib, 33 | widget_name=pseudo_name, 34 | widget_config_method=config_method)) 35 | if name: 36 | warn('Menu items cannot be named') 37 | return None, self.root_widget 38 | 39 | @property 40 | def named_widgets(self): 41 | return self.parent_directive.named_widgets 42 | 43 | 44 | if sys.version_info < (3, 6): 45 | Directive.Registry.register(Menu) 46 | -------------------------------------------------------------------------------- /example/ExampleWindow.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Open 5 | 6 | Checkbutton 7 | Option 1 8 | Option 2 9 | 10 | 11 | 12 | 13 | 14 | 16 | 17 | Option 1 18 | 19 | 20 | Option 2 21 | 22 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /tkpf/Component.py: -------------------------------------------------------------------------------- 1 | import xml.etree.ElementTree as Xml 2 | import yaml 3 | from typing import Union 4 | import tkinter as tk 5 | 6 | from tkpf import Directive 7 | from tkpf import parser 8 | 9 | 10 | class Component(Directive.Structural): 11 | _counter = 0 # Unique ID for inclusion in the Tkinter name 12 | 13 | template = None 14 | template_path = None 15 | template_yaml = None 16 | 17 | def __init__(self, parent_widget, parent_directive, model=None, **_): 18 | super().__init__(parent_widget, parent_directive, model) 19 | self.bindings = {} 20 | 21 | def create(self, parent, **_): 22 | tree = self.parsed_template() 23 | if tree is not None: 24 | if isinstance(parent, tk.Wm): 25 | return self.construct(tree, parent) 26 | else: 27 | root_widget = tk.Frame(parent, name=type(self).__name__.lower() + str(self._counter)) 28 | type(self)._counter += 1 29 | self.construct(tree, root_widget) 30 | return root_widget 31 | 32 | def parsed_template(self): 33 | if self.template: 34 | return parser.wrap(Xml.fromstring(self.template)) 35 | elif self.template_yaml: 36 | return parser.wrap(yaml.load(self.template_yaml)) 37 | elif self.template_path: 38 | if self.template_path.lower().endswith('.xml'): 39 | return parser.wrap(Xml.parse(self.template_path)) 40 | elif self.template_path.lower().endswith('.yaml'): 41 | with open(self.template_path) as bf: 42 | return parser.wrap(yaml.load(bf)) 43 | raise Exception('Component template not specified') 44 | 45 | def construct(self, elem, parent: Union[tk.Widget, tk.Wm]): 46 | ret = super().construct(elem, parent) 47 | 48 | if isinstance(ret, tk.Widget): 49 | if any(ch.winfo_manager() == 'grid' for ch in ret.children.values()): 50 | columns = ret.grid_size() 51 | for i in range(columns[0]): 52 | ret.grid_columnconfigure(i, weight=1) 53 | 54 | return ret 55 | -------------------------------------------------------------------------------- /tkpf/Binding.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | from typing import Callable 3 | from warnings import warn 4 | 5 | from tkpf import Bindable 6 | from tkpf.ViewModel import ViewModel 7 | 8 | _type_mapping = { 9 | int: tk.IntVar, 10 | str: tk.StringVar, 11 | bool: tk.BooleanVar, 12 | float: tk.DoubleVar 13 | } 14 | 15 | 16 | class Binding: 17 | def __init__(self, 18 | source: ViewModel, source_prop: Bindable, 19 | target: tk.Widget, target_prop: str, 20 | to_model: bool, to_view: bool, 21 | config_method: Callable=None): 22 | self.source = source 23 | self.source_property = source_prop 24 | self.var = _type_mapping[source_prop.dtype]() 25 | self.target = target 26 | self.target_property = target_prop 27 | self.var.set(source_prop.fget(source)) 28 | self.to_view = to_view 29 | self.to_model = to_model 30 | self._subscribe_to_var(self.notify_to_model) 31 | 32 | if 'variable' not in target_prop: 33 | if not config_method: 34 | config_method = target.config 35 | if to_view: 36 | self._subscribe_to_var(lambda val: config_method(**{target_prop: val})) 37 | if to_model: 38 | warn('Property "{}" is not a variable: binding back to model not supported'.format(target_prop)) 39 | 40 | def _subscribe_to_var(self, observer): 41 | """ 42 | Register a callback to be called when the value of ``self.var`` changes 43 | :param observer: a callable accepting one parameter. The new value will be passed in that. 44 | """ 45 | if hasattr(self.var, 'trace_add'): 46 | self.var.trace_add('write', lambda *_: observer(self.safe_get())) 47 | else: 48 | self.var.trace('w', lambda *_: observer(self.safe_get())) 49 | 50 | def safe_get(self): 51 | try: 52 | return self.source_property.dtype(self.var.get()) 53 | except tk.TclError: 54 | return self.source_property.dtype() 55 | 56 | def notify_to_model(self, val): 57 | if self.to_model: 58 | self.source_property.bindings.remove(self) 59 | self.source_property.fset(self.source, val) 60 | self.source_property.bindings.append(self) 61 | 62 | def notify_to_view(self, val, source): 63 | if self.to_view and self.source is source: 64 | self.var.set(val) 65 | 66 | @staticmethod 67 | def is_binding_expr(s): 68 | return isinstance(s, str) and (s.startswith('[') and s.endswith(']') or s.startswith('(') and s.endswith(')')) 69 | 70 | -------------------------------------------------------------------------------- /example/ExampleWindow.yaml: -------------------------------------------------------------------------------- 1 | Frame: 2 | pack-anchor: nw 3 | pack-padx: 5 4 | children: 5 | - Menu: 6 | children: 7 | - Menu: 8 | label: File 9 | name: themenu 10 | children: 11 | - Command: 12 | label: Open 13 | command: file_open 14 | - Separator: ~ 15 | - Checkbutton: 16 | label: Checkbutton 17 | - Radiobutton: 18 | label: Option 1 19 | variable: '[(choice)]' 20 | value: 1 21 | - Radiobutton: 22 | label: Option 2 23 | variable: '[(choice)]' 24 | value: 2 25 | 26 | - Notebook: 27 | pack-anchor: w 28 | pack-fill: x 29 | children: 30 | - Frame: 31 | tab-text: Main 32 | children: 33 | - LabelFrame: 34 | text: Options 35 | pack-padx: 5 36 | children: 37 | - OptionMenu: 38 | pack-anchor: w 39 | values: '[dropdown_options]' 40 | variable: (optionmenu_selected) 41 | name: optionmenu 42 | - Radiobutton: 43 | text: Option 1 44 | pack-anchor: w 45 | variable: '[(choice)]' 46 | value: 1 47 | - Radiobutton: 48 | text: Option 2 49 | pack-anchor: w 50 | variable: '[(choice)]' 51 | value: 2 52 | - Combobox: 53 | pack-anchor: w 54 | textvariable: (combobox_selected) 55 | values: '[dropdown_options]' 56 | name: combobox 57 | - NumericEntry: 58 | pack-anchor: w 59 | textvariable: (numeric_value) 60 | - ExampleComponent: 61 | pack-anchor: w 62 | custom-text: Custom component text 63 | - Button: 64 | text: Do stuff 65 | command: do_stuff 66 | - Frame: 67 | tab-text: Grid layout 68 | children: 69 | - Label: 70 | grid-row: 0 71 | grid-column: 0 72 | text: Cell 73 | - Label: 74 | grid-row: 0 75 | grid-column: 1 76 | text: Cellll 77 | - Label: 78 | grid-row: 1 79 | grid-column: 0 80 | text: Cellllll 81 | - Label: 82 | grid-row: 1 83 | grid-column: 1 84 | text: Cellllllll 85 | - Button: 86 | grid-row: 2 87 | grid-column: 0 88 | text: Button 89 | - Button: 90 | grid-row: 2 91 | grid-column: 1 92 | text: Butttton 93 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | `tkpf` is a library for building Tkinter GUIs in a paradigm 2 | influenced by WPF (Windows Presentation Foundation) and Angular. 3 | 4 | Main features are: 5 | 6 | - Declarative view hierarchy and layout in XML or YAML 7 | - One-way and two-way data binding 8 | - Componentization support 9 | 10 | ![tkpf](https://user-images.githubusercontent.com/6771275/28272256-3ddb29a4-6b0b-11e7-8072-c962235d58d8.png) 11 | ![x1](https://user-images.githubusercontent.com/6771275/28181423-d9c4bfd4-6808-11e7-955a-c0e18219e609.PNG) 12 | ![mac](https://user-images.githubusercontent.com/6771275/48013133-e82a6e80-e123-11e8-92c0-b0a13467dc7e.png) 13 | 14 | 15 | # Tutorial 16 | ## The layout template 17 | You specify the GUI in XML or YAML format. Here is a simple example, `ExampleWindow.xml`: 18 | 19 | ```xml 20 | 21 | 22 | 23 | Option 1 24 | 25 | 26 | Option 2 27 | 28 | 30 | 31 | 32 | 33 | ``` 34 | 35 | As you can see, the XML tag names correspond to Tkinter widget class names, 36 | while XML attributes to their arguments. 37 | `tkpf` is opinionated in that it always uses the better looking `ttk` themed widgets 38 | when available. 39 | 40 | Options such as `pack-anchor="nw"` or `grid-row="0"` specify the layout and will be passed to the appropriate 41 | Tkinter layout manager method, in this case `.pack(anchor='nw')`. 42 | 43 | On how to specify a GUI in YAML format, see `example/ExampleWindow.yaml`. 44 | 45 | ## The view class 46 | You display the GUI by creating a class derived from `Window` and showing it. 47 | You have to supply the viewmodel in the constructor. 48 | 49 | ```python 50 | class ExampleWindow(Window): 51 | template_path = 'ExampleWindow.xml' # 'ExampleWindow.yaml' works too 52 | 53 | ExampleWindow(ExampleModel()).show() 54 | ``` 55 | If you want to keep the layout XML in this file inline, you can do that too: 56 | 57 | ```python 58 | class ExampleWindow(Window): 59 | template = '' 60 | ``` 61 | 62 | or 63 | 64 | ```python 65 | class ExampleWindow(Window): 66 | template_yaml = ''' 67 | Label: 68 | text: Some text 69 | ''' 70 | ``` 71 | 72 | 73 | 74 | Setting the window title: 75 | 76 | ```python 77 | def __init__(self, model): 78 | super().__init__(model) 79 | self.title = 'My application' 80 | ``` 81 | 82 | In the view class you can write event handlers. Make that button work for example: 83 | 84 | ```python 85 | def do_stuff(self): 86 | self.combobox.config(state='disabled') 87 | ``` 88 | 89 | This also shows how you can access widgets by name in methods of the view class. But if you prefer you can access them dynamically like this: 90 | 91 | ```python 92 | self.named_widgets['combobox'] 93 | ``` 94 | 95 | ## The viewmodel class 96 | ```python 97 | 98 | class ExampleModel(ViewModel): 99 | choice = Bindable(AutoProperty(1)) 100 | available_suboptions = Bindable(AutoProperty()) 101 | selected_suboption = Bindable(AutoProperty()) 102 | 103 | def __init__(self): 104 | super().__init__() 105 | self.available_suboptions = ('suboption1', 'suboption2') 106 | ``` 107 | 108 | `AutoProperty` is similar to a C# autogenerated property. By default its datatype is `str`. 109 | You can supply either a default value or a type to its constructor. 110 | 111 | `Bindable` is a decorator that you can use on any property to return a bindable property. 112 | It has to know the data type of the wrapped property, so please specify its return type with a type annotation: 113 | ```python 114 | @Bindable 115 | @property 116 | def foo() -> int: 117 | return 1 118 | ``` 119 | 120 | `AutoProperty` takes care of that for you. 121 | 122 | Only `int`, `bool`, `float` and `str` types are supported for Tkinter bindings, though for the combobox 123 | values, you can assign a Python tuple. 124 | 125 | If an event handler is not found on the view class, it will be looked up on the viewmodel as well. 126 | 127 | ## Data binding syntax 128 | In the XML you specify the direction of data binding with a syntax similar to that of Angular: 129 | 130 | ``` 131 | values="[available_suboptions]" 132 | ``` 133 | is a one-way binding from data source to view target, 134 | ``` 135 | textvariable="(selected_suboption)" 136 | ``` 137 | is a one-way binding from view target to data source, and 138 | ``` 139 | variable="[(choice)]" 140 | ``` 141 | is a two-way binding. 142 | 143 | ## Using custom widgets 144 | You can use custom widgets derived from Tkinter widget classes. 145 | The only thing you have to do is call 146 | 147 | ```python 148 | Directive.Registry.register(YourCustomWidgetClass) 149 | ``` 150 | 151 | before loading a template that uses it. 152 | 153 | ## Components 154 | `tkpf` supports breaking up your GUI into components. 155 | Here's an example of a progressbar component with its own viewmodel: 156 | 157 | ```python 158 | class ProgressbarModel(ViewModel): 159 | value = BindableProperty(0) 160 | target = BindableProperty(100) 161 | 162 | 163 | class CustomProgressbar(Component): 164 | template = '' 165 | ``` 166 | 167 | and you can use it like this: 168 | ```xml 169 | 170 | ``` 171 | 172 | where `progressbar_model` is an attribute or property on your main viewmodel. 173 | 174 | On Python 3.5 you have to register your component before using it. On Python 3.6+ that is automatic. 175 | 176 | ```python 177 | Directive.Registry.register(CustomProgressbar) 178 | ``` 179 | 180 | It is planned that you will be able to add add custom, bindable attributes to components, like this: 181 | 182 | ```python 183 | class ExampleComponent(Component): 184 | template = '' 185 | 186 | def config(self, **kwargs): 187 | self.thelabel.config(text=kwargs['custom-text']) 188 | ``` 189 | 190 | and then use them like this: 191 | ```xml 192 | 193 | ``` 194 | The only requirement is that the attribute name contains a hyphen. 195 | ## Caveats 196 | `tkpf` only supports Python 3.5+. 197 | 198 | This is a work in progress. Also my first attempt at creating a library. Look at the project issues to see what's not supported yet. 199 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | 9 | This version of the GNU Lesser General Public License incorporates 10 | the terms and conditions of version 3 of the GNU General Public 11 | License, supplemented by the additional permissions listed below. 12 | 13 | 0. Additional Definitions. 14 | 15 | As used herein, "this License" refers to version 3 of the GNU Lesser 16 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 17 | General Public License. 18 | 19 | "The Library" refers to a covered work governed by this License, 20 | other than an Application or a Combined Work as defined below. 21 | 22 | An "Application" is any work that makes use of an interface provided 23 | by the Library, but which is not otherwise based on the Library. 24 | Defining a subclass of a class defined by the Library is deemed a mode 25 | of using an interface provided by the Library. 26 | 27 | A "Combined Work" is a work produced by combining or linking an 28 | Application with the Library. The particular version of the Library 29 | with which the Combined Work was made is also called the "Linked 30 | Version". 31 | 32 | The "Minimal Corresponding Source" for a Combined Work means the 33 | Corresponding Source for the Combined Work, excluding any source code 34 | for portions of the Combined Work that, considered in isolation, are 35 | based on the Application, and not on the Linked Version. 36 | 37 | The "Corresponding Application Code" for a Combined Work means the 38 | object code and/or source code for the Application, including any data 39 | and utility programs needed for reproducing the Combined Work from the 40 | Application, but excluding the System Libraries of the Combined Work. 41 | 42 | 1. Exception to Section 3 of the GNU GPL. 43 | 44 | You may convey a covered work under sections 3 and 4 of this License 45 | without being bound by section 3 of the GNU GPL. 46 | 47 | 2. Conveying Modified Versions. 48 | 49 | If you modify a copy of the Library, and, in your modifications, a 50 | facility refers to a function or data to be supplied by an Application 51 | that uses the facility (other than as an argument passed when the 52 | facility is invoked), then you may convey a copy of the modified 53 | version: 54 | 55 | a) under this License, provided that you make a good faith effort to 56 | ensure that, in the event an Application does not supply the 57 | function or data, the facility still operates, and performs 58 | whatever part of its purpose remains meaningful, or 59 | 60 | b) under the GNU GPL, with none of the additional permissions of 61 | this License applicable to that copy. 62 | 63 | 3. Object Code Incorporating Material from Library Header Files. 64 | 65 | The object code form of an Application may incorporate material from 66 | a header file that is part of the Library. You may convey such object 67 | code under terms of your choice, provided that, if the incorporated 68 | material is not limited to numerical parameters, data structure 69 | layouts and accessors, or small macros, inline functions and templates 70 | (ten or fewer lines in length), you do both of the following: 71 | 72 | a) Give prominent notice with each copy of the object code that the 73 | Library is used in it and that the Library and its use are 74 | covered by this License. 75 | 76 | b) Accompany the object code with a copy of the GNU GPL and this license 77 | document. 78 | 79 | 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, 82 | taken together, effectively do not restrict modification of the 83 | portions of the Library contained in the Combined Work and reverse 84 | engineering for debugging such modifications, if you also do each of 85 | the following: 86 | 87 | a) Give prominent notice with each copy of the Combined Work that 88 | the Library is used in it and that the Library and its use are 89 | covered by this License. 90 | 91 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 92 | document. 93 | 94 | c) For a Combined Work that displays copyright notices during 95 | execution, include the copyright notice for the Library among 96 | these notices, as well as a reference directing the user to the 97 | copies of the GNU GPL and this license document. 98 | 99 | d) Do one of the following: 100 | 101 | 0) Convey the Minimal Corresponding Source under the terms of this 102 | License, and the Corresponding Application Code in a form 103 | suitable for, and under terms that permit, the user to 104 | recombine or relink the Application with a modified version of 105 | the Linked Version to produce a modified Combined Work, in the 106 | manner specified by section 6 of the GNU GPL for conveying 107 | Corresponding Source. 108 | 109 | 1) Use a suitable shared library mechanism for linking with the 110 | Library. A suitable mechanism is one that (a) uses at run time 111 | a copy of the Library already present on the user's computer 112 | system, and (b) will operate properly with a modified version 113 | of the Library that is interface-compatible with the Linked 114 | Version. 115 | 116 | e) Provide Installation Information, but only if you would otherwise 117 | be required to provide such information under section 6 of the 118 | GNU GPL, and only to the extent that such information is 119 | necessary to install and execute a modified version of the 120 | Combined Work produced by recombining or relinking the 121 | Application with a modified version of the Linked Version. (If 122 | you use option 4d0, the Installation Information must accompany 123 | the Minimal Corresponding Source and Corresponding Application 124 | Code. If you use option 4d1, you must provide the Installation 125 | Information in the manner specified by section 6 of the GNU GPL 126 | for conveying Corresponding Source.) 127 | 128 | 5. Combined Libraries. 129 | 130 | You may place library facilities that are a work based on the 131 | Library side by side in a single library together with other library 132 | facilities that are not Applications and are not covered by this 133 | License, and convey such a combined library under terms of your 134 | choice, if you do both of the following: 135 | 136 | a) Accompany the combined library with a copy of the same work based 137 | on the Library, uncombined with any other library facilities, 138 | conveyed under the terms of this License. 139 | 140 | b) Give prominent notice with the combined library that part of it 141 | is a work based on the Library, and explaining where to find the 142 | accompanying uncombined form of the same work. 143 | 144 | 6. Revised Versions of the GNU Lesser General Public License. 145 | 146 | The Free Software Foundation may publish revised and/or new versions 147 | of the GNU Lesser General Public License from time to time. Such new 148 | versions will be similar in spirit to the present version, but may 149 | differ in detail to address new problems or concerns. 150 | 151 | Each version is given a distinguishing version number. If the 152 | Library as you received it specifies that a certain numbered version 153 | of the GNU Lesser General Public License "or any later version" 154 | applies to it, you have the option of following the terms and 155 | conditions either of that published version or of any later version 156 | published by the Free Software Foundation. If the Library as you 157 | received it does not specify a version number of the GNU Lesser 158 | General Public License, you may choose any version of the GNU Lesser 159 | General Public License ever published by the Free Software Foundation. 160 | 161 | If the Library as you received it specifies that a proxy can decide 162 | whether future versions of the GNU Lesser General Public License shall 163 | apply, that proxy's public statement of acceptance of any version is 164 | permanent authorization for you to choose that version for the 165 | Library. 166 | -------------------------------------------------------------------------------- /tkpf/Directive.py: -------------------------------------------------------------------------------- 1 | from copy import copy 2 | from typing import Union 3 | import tkinter as tk 4 | from tkinter import ttk 5 | 6 | from tkpf.Binding import Binding 7 | from tkpf.NumericEntry import NumericEntry 8 | from tkpf.OptionMenu import OptionMenu 9 | 10 | 11 | _variable_counterparts = { 12 | ('Button', 'text'): 'textvariable', 13 | ('Checkbutton', 'text'): 'textvariable', 14 | ('Menubutton', 'text'): 'textvariable', 15 | ('RadioButton', 'text'): 'textvariable', 16 | ('Label', 'text'): 'textvariable', 17 | ('Message', 'text'): 'textvariable', 18 | ('Progressbar', 'value'): 'variable' 19 | } 20 | 21 | 22 | class Registry: 23 | widgets = copy(tk.__dict__) 24 | widgets.update(ttk.__dict__) 25 | widgets.update({'NumericEntry': NumericEntry, 'OptionMenu': OptionMenu}) 26 | directives = {} 27 | 28 | @classmethod 29 | def register(cls, typ: type): 30 | name = typ.__name__ 31 | if issubclass(typ, Directive): 32 | cls.directives[name] = typ 33 | elif issubclass(cls, tk.Widget): 34 | cls.widgets[name] = typ 35 | 36 | 37 | class Directive: 38 | pass 39 | 40 | 41 | class Structural(Directive): 42 | @classmethod 43 | def __init_subclass__(cls): 44 | super().__init_subclass__() 45 | Registry.register(cls) 46 | 47 | def __init__(self, parent_widget, parent_directive, model=None): 48 | self.parent_widget = parent_widget 49 | self.parent_directive = parent_directive 50 | self.model = model 51 | self.bindings = {} 52 | self._named_widgets = {} 53 | self.root_widget = self.create(parent_widget) 54 | 55 | @property 56 | def named_widgets(self): 57 | return self._named_widgets 58 | 59 | def __getattr__(self, item): 60 | if item in self.named_widgets: 61 | return self.named_widgets[item] 62 | else: 63 | raise AttributeError('{} has no widget named "{}"'.format(type(self.model), item)) 64 | 65 | def create(self, parent): 66 | """ Create the view hierarchy. 67 | 68 | :return: the root widget of the newly created view hierarchy 69 | """ 70 | 71 | def construct(self, elem, parent: Union[tk.Widget, tk.Wm]): 72 | """ 73 | Given a parsed template and a parent widget, construct the view hierarchy, 74 | with the given widget as its parent 75 | 76 | :param elem: the parsed template 77 | :param parent: the parent widget 78 | :return: the newly constructed view hierarchy, in the form of its root widget or directive 79 | """ 80 | 81 | text = None 82 | if elem.text and elem.text.strip(): 83 | text = elem.text.strip() 84 | 85 | directive, widget = self.add_child(parent, elem.name, elem.attrib, text) 86 | 87 | for child in elem.children: 88 | (directive or self).construct(child, widget) 89 | 90 | return directive or widget 91 | 92 | def add_child(self, parent, classname, attrib, text=None) -> tuple: 93 | """ This method gets called when, during tree traversal, this directive contains a child element. 94 | This method should decide what to do with that child, and return (if applicable) 95 | the root directive and root widget resulting from that decision """ 96 | if text: 97 | attrib['text'] = text 98 | directive, widget = self.inflate(parent, classname, 99 | widget_name=attrib.pop('name', None), 100 | viewmodel_expr=attrib.pop('tkpf-model', None)) 101 | self.process_attributes(widget, self.resolve_bindings(widget, attrib)) 102 | return directive, widget 103 | 104 | def inflate(self, parent, classname, widget_name=None, viewmodel_expr=None): 105 | """ Find and instantiate one widget or directive class, attaching it to the given widget as parent """ 106 | 107 | if classname in Registry.directives: 108 | if viewmodel_expr: 109 | viewmodel = getattr(self.model, viewmodel_expr[1:-1]) 110 | else: 111 | viewmodel = self.model 112 | 113 | cls = Registry.directives[classname] 114 | directive = cls(parent, self, model=viewmodel) 115 | widget = directive.root_widget 116 | elif classname in Registry.widgets: 117 | cls = Registry.widgets[classname] 118 | widget = cls(parent, name=widget_name) 119 | directive = None 120 | else: 121 | raise AttributeError('Component or widget "{}" does not exist or was not registered'.format(classname)) 122 | 123 | if widget_name: 124 | self.named_widgets[widget_name] = directive or widget 125 | 126 | return directive, widget 127 | 128 | def command_lookup(self, name): 129 | cur = self 130 | while cur and not hasattr(cur, name): 131 | cur = cur.parent_directive 132 | if cur and hasattr(cur, name): 133 | return getattr(cur, name) 134 | elif hasattr(self.model, name): 135 | return getattr(self.model, name) 136 | else: 137 | raise AttributeError('Event handler "{}" not found'.format(name)) 138 | 139 | def resolve_bindings(self, widget, attrib, **kwargs): 140 | """ Take a dictionary of attributes and replace command and data binding expressions with 141 | actual references """ 142 | ret = copy(attrib) 143 | for key, name in attrib.items(): 144 | if 'command' in key: 145 | ret[key] = self.command_lookup(name) 146 | elif Binding.is_binding_expr(name): 147 | ret.update(self.bind(key, name, widget, **kwargs)) 148 | return ret 149 | 150 | @staticmethod 151 | def process_attributes(widget, attrib): 152 | config_args = {k: v for k, v in attrib.items() if '-' not in k} 153 | pack_args = {k[5:]: v for k, v in attrib.items() if k.startswith('pack-')} 154 | grid_args = {k[5:]: v for k, v in attrib.items() if k.startswith('grid-')} 155 | place_args = {k[6:]: v for k, v in attrib.items() if k.startswith('place-')} 156 | 157 | widget.config(**config_args) 158 | if grid_args: 159 | widget.grid(**grid_args) 160 | elif place_args: 161 | widget.place(**place_args) 162 | elif not isinstance(widget, tk.Menu): 163 | widget.pack(**pack_args) 164 | 165 | def bind(self, target_property, binding_expr, 166 | widget=None, widget_name=None, 167 | widget_classname=None, widget_config_method=None) -> dict: 168 | """ 169 | Create a binding 170 | 171 | :param target_property: the name of the property that we're binding to 172 | :param binding_expr: the binding expression 173 | :param widget: the Tkinter object 174 | :param widget_name: the name of the Tkinter object 175 | :param widget_classname: the classname of the Tkinter object 176 | :param widget_config_method: the method that should be invoked by the binding in the case of a 177 | non-variable target property 178 | :return: a dictionary that should be passed to the config method to finally create the binding 179 | """ 180 | to_view = False 181 | to_model = False 182 | if widget: 183 | widget_classname = type(widget).__name__ 184 | if not widget_name: 185 | widget_name = str(widget) 186 | 187 | if binding_expr.startswith('[') and binding_expr.endswith(']'): 188 | binding_expr = binding_expr[1:-1] 189 | to_view = True 190 | if binding_expr.startswith('(') and binding_expr.endswith(')'): 191 | binding_expr = binding_expr[1:-1] 192 | to_model = True 193 | if hasattr(type(self.model), binding_expr): 194 | source_property = getattr(type(self.model), binding_expr) 195 | else: 196 | raise AttributeError('{} has no attribute "{}"'.format(type(self.model), binding_expr)) 197 | original_target = None 198 | if (widget_classname, target_property) in _variable_counterparts: 199 | original_target = target_property 200 | target_property = _variable_counterparts[widget_classname, target_property] 201 | binding = Binding(source=self.model, source_prop=source_property, 202 | target=widget, target_prop=target_property, 203 | to_model=to_model, to_view=to_view, 204 | config_method=widget_config_method) 205 | 206 | # Unsubscribe previous binding 207 | binding_key = widget_name + '.Tkpf_targetprop:' + binding.target_property 208 | if binding_key in self.bindings: 209 | previous = self.bindings.pop(binding_key) 210 | previous.source_property.bindings.remove(previous) 211 | 212 | # Subscribe new binding 213 | self.bindings[binding_key] = binding 214 | source_property.bindings.append(binding) 215 | 216 | if 'variable' in target_property: 217 | ret = {target_property: binding.var} 218 | if original_target: 219 | ret[original_target] = None 220 | return ret 221 | else: 222 | return {target_property: getattr(self.model, binding_expr)} 223 | 224 | def config(self, **kwargs): 225 | pass 226 | --------------------------------------------------------------------------------