├── .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 |
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 | 
11 | 
12 | 
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 |
--------------------------------------------------------------------------------