├── tests ├── __init__.py ├── test_parser.py ├── test_store.py └── test_expr_compile.py ├── layoutx ├── tools │ ├── .gitkeep │ ├── __init__.py │ └── designer.py ├── widgets │ ├── listbox.py │ ├── label.py │ ├── seperator.py │ ├── button.py │ ├── box.py │ ├── progressbar.py │ ├── notebook.py │ ├── spinbox.py │ ├── checkbox.py │ ├── radiobutton.py │ ├── splitpane.py │ ├── combobox.py │ ├── tree.py │ ├── formitem.py │ ├── __init__.py │ ├── scale.py │ ├── logger.py │ ├── sheet.py │ ├── drop_target.py │ ├── scroll_frame.py │ ├── input.py │ ├── textarea.py │ ├── widget.py │ ├── imageviewer.py │ └── calendar.py ├── resources │ ├── favicon.gif │ └── favicon.ico ├── __init__.py ├── tkDnD │ ├── __init__.py │ └── TkinterDnD.py ├── install_tkdnd.py ├── store.py ├── _parser.py ├── app.py ├── view.py ├── utils.py └── _registry.py ├── requirements-docs.txt ├── requirements.txt ├── MANIFEST.in ├── src-docs ├── img │ ├── Banner.png │ ├── logo.png │ ├── designer.GIF │ ├── favicon.ico │ ├── Banner_no_bg.png │ ├── layout │ │ ├── basic.png │ │ ├── inline.png │ │ └── nested.png │ ├── showcase │ │ ├── todo.png │ │ ├── facts.png │ │ └── imageviewer.png │ └── index │ │ ├── designer.png │ │ ├── example-gui.png │ │ ├── getting-started.png │ │ └── getting-started-theme.png ├── store.md ├── view.md ├── architecture.md ├── advanced.md ├── layout.md ├── index.md ├── widgets.md └── showcase.md ├── .vscode └── launch.json ├── mkdocs.yml ├── LICENSE ├── .gitlab-ci.yml ├── demo ├── cat_facts.py ├── todo_list.py └── show_image.py ├── .gitignore ├── setup.py └── Readme.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /layoutx/tools/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /layoutx/tools/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /layoutx/widgets/listbox.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements-docs.txt: -------------------------------------------------------------------------------- 1 | mkdocs-material 2 | plantuml-markdown -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | ttkthemes 2 | pypugjs 3 | rx 4 | ttkwidgets 5 | pygments -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include Readme.md 3 | include layoutx/resources/*.ico -------------------------------------------------------------------------------- /src-docs/img/Banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bomberus/LayoutX/HEAD/src-docs/img/Banner.png -------------------------------------------------------------------------------- /src-docs/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bomberus/LayoutX/HEAD/src-docs/img/logo.png -------------------------------------------------------------------------------- /src-docs/img/designer.GIF: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bomberus/LayoutX/HEAD/src-docs/img/designer.GIF -------------------------------------------------------------------------------- /src-docs/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bomberus/LayoutX/HEAD/src-docs/img/favicon.ico -------------------------------------------------------------------------------- /layoutx/resources/favicon.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bomberus/LayoutX/HEAD/layoutx/resources/favicon.gif -------------------------------------------------------------------------------- /layoutx/resources/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bomberus/LayoutX/HEAD/layoutx/resources/favicon.ico -------------------------------------------------------------------------------- /src-docs/img/Banner_no_bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bomberus/LayoutX/HEAD/src-docs/img/Banner_no_bg.png -------------------------------------------------------------------------------- /src-docs/img/layout/basic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bomberus/LayoutX/HEAD/src-docs/img/layout/basic.png -------------------------------------------------------------------------------- /src-docs/img/layout/inline.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bomberus/LayoutX/HEAD/src-docs/img/layout/inline.png -------------------------------------------------------------------------------- /src-docs/img/layout/nested.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bomberus/LayoutX/HEAD/src-docs/img/layout/nested.png -------------------------------------------------------------------------------- /src-docs/img/showcase/todo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bomberus/LayoutX/HEAD/src-docs/img/showcase/todo.png -------------------------------------------------------------------------------- /src-docs/img/index/designer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bomberus/LayoutX/HEAD/src-docs/img/index/designer.png -------------------------------------------------------------------------------- /src-docs/img/showcase/facts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bomberus/LayoutX/HEAD/src-docs/img/showcase/facts.png -------------------------------------------------------------------------------- /src-docs/img/index/example-gui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bomberus/LayoutX/HEAD/src-docs/img/index/example-gui.png -------------------------------------------------------------------------------- /src-docs/img/index/getting-started.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bomberus/LayoutX/HEAD/src-docs/img/index/getting-started.png -------------------------------------------------------------------------------- /src-docs/img/showcase/imageviewer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bomberus/LayoutX/HEAD/src-docs/img/showcase/imageviewer.png -------------------------------------------------------------------------------- /src-docs/img/index/getting-started-theme.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bomberus/LayoutX/HEAD/src-docs/img/index/getting-started-theme.png -------------------------------------------------------------------------------- /layoutx/widgets/label.py: -------------------------------------------------------------------------------- 1 | from .widget import Widget 2 | from tkinter import ttk 3 | 4 | 5 | class Label(Widget): 6 | def __init__(self, master, **kwargs): 7 | super().__init__(tk=ttk.Label(master=master), **kwargs) 8 | -------------------------------------------------------------------------------- /layoutx/__init__.py: -------------------------------------------------------------------------------- 1 | """Top-level package for LayoutX""" 2 | 3 | __author__ = """Pascal Maximilian Bremer""" 4 | __email__ = 'noname' 5 | __version__ = '0.9' 6 | 7 | 8 | from .app import Application 9 | 10 | app = Application.instance() -------------------------------------------------------------------------------- /layoutx/widgets/seperator.py: -------------------------------------------------------------------------------- 1 | from .widget import Widget 2 | from tkinter import ttk 3 | 4 | 5 | class Sep(Widget): 6 | def __init__(self, master, **kwargs): 7 | super().__init__(tk=ttk.Separator( 8 | master=master 9 | ), **kwargs) -------------------------------------------------------------------------------- /layoutx/widgets/button.py: -------------------------------------------------------------------------------- 1 | from .widget import Widget 2 | from tkinter import ttk, StringVar 3 | 4 | 5 | class Button(Widget): 6 | def __init__(self, master, **kwargs): 7 | super().__init__( 8 | tk = ttk.Button(master=master), **kwargs) 9 | -------------------------------------------------------------------------------- /layoutx/widgets/box.py: -------------------------------------------------------------------------------- 1 | from .widget import Widget 2 | from tkinter import ttk 3 | 4 | 5 | class Box(Widget): 6 | def __init__(self, master,**kwargs): 7 | if kwargs.get("node").text: 8 | super().__init__(tk=ttk.LabelFrame(master=master), **kwargs) 9 | else: 10 | super().__init__(tk=ttk.Frame(master=master), **kwargs) 11 | 12 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Python: Current File", 9 | "type": "python", 10 | "request": "launch", 11 | "program": "${file}", 12 | "console": "integratedTerminal" 13 | } 14 | ] 15 | } -------------------------------------------------------------------------------- /layoutx/widgets/progressbar.py: -------------------------------------------------------------------------------- 1 | from .widget import Widget 2 | from tkinter import ttk, IntVar 3 | 4 | class ProgressBar(Widget): 5 | def __init__(self, master, **kwargs): 6 | self._value = IntVar() 7 | super().__init__(tk=ttk.Progressbar( 8 | master=master, 9 | variable=self._value ), **kwargs) 10 | self.connect_to_prop("value", self.on_changed_value) 11 | 12 | def on_changed_value(self, value): 13 | self._value.set(int(value)) 14 | 15 | def on_disposed(self): 16 | self._value = None -------------------------------------------------------------------------------- /layoutx/tkDnD/__init__.py: -------------------------------------------------------------------------------- 1 | # dnd actions 2 | PRIVATE = 'private' 3 | NONE = 'none' 4 | ASK = 'ask' 5 | COPY = 'copy' 6 | MOVE = 'move' 7 | LINK = 'link' 8 | REFUSE_DROP = 'refuse_drop' 9 | # dnd types 10 | DND_TEXT = 'DND_Text' 11 | DND_FILES = 'DND_Files' 12 | DND_ALL = '*' 13 | CF_UNICODETEXT = 'CF_UNICODETEXT' 14 | CF_TEXT = 'CF_TEXT' 15 | CF_HDROP = 'CF_HDROP' 16 | FileGroupDescriptor = 'FileGroupDescriptor - FileContents'# ?? 17 | FileGroupDescriptorW = 'FileGroupDescriptorW - FileContents'# ?? 18 | 19 | from layoutx.tkDnD import TkinterDnD 20 | -------------------------------------------------------------------------------- /layoutx/widgets/notebook.py: -------------------------------------------------------------------------------- 1 | from .widget import Widget 2 | from tkinter import ttk 3 | from tkinter.constants import HORIZONTAL, VERTICAL 4 | 5 | class Notebook(Widget): 6 | def __init__(self,master, **kwargs): 7 | super().__init__( 8 | tk=ttk.Notebook( 9 | master=master), **kwargs) 10 | 11 | def forget_children(self): 12 | for child in self.tk.tabs(): 13 | self.tk.forget(child) 14 | 15 | def place_children(self, changed_value=None): 16 | self.forget_children() 17 | 18 | for child in self.children: 19 | if child and not child.hidden: 20 | self.tk.add(child.tk, text=child.text) 21 | 22 | child._node.placed() -------------------------------------------------------------------------------- /layoutx/widgets/spinbox.py: -------------------------------------------------------------------------------- 1 | from .widget import Widget 2 | from tkinter import ttk, StringVar 3 | 4 | class SpinBox(Widget): 5 | def __init__(self, master, **kwargs): 6 | self._textv = StringVar() 7 | super().__init__( 8 | tk=ttk.Spinbox(master, textvariable=self._textv), **kwargs 9 | ) 10 | self._setter = self.connect_to_prop("value", self.on_changed_value) 11 | self._trace = self._textv.trace_add("write", 12 | lambda *_: self._setter(self._textv.get()) 13 | ) 14 | 15 | def on_changed_value(self, value): 16 | if value: 17 | self._textv.set(value) 18 | 19 | def on_disposed(self): 20 | self._textv.trace_remove("write", self._trace) 21 | self._setter = None -------------------------------------------------------------------------------- /layoutx/widgets/checkbox.py: -------------------------------------------------------------------------------- 1 | from .widget import Widget 2 | from tkinter import ttk, IntVar 3 | 4 | 5 | class CheckBox(Widget): 6 | def __init__(self, master, **kwargs): 7 | self._value = IntVar() 8 | super().__init__( 9 | tk=ttk.Checkbutton( 10 | master=master, 11 | variable=self._value 12 | ), **kwargs 13 | ) 14 | 15 | self._setter = self.connect_to_prop("value", self.on_changed_value) 16 | self._trace = self._value.trace_add("write", 17 | lambda *_: self._setter(self._value.get()) 18 | ) 19 | 20 | def on_changed_value(self, value): 21 | self._value.set(value) 22 | 23 | def on_disposed(self): 24 | self._value.trace_remove("write", self._trace) 25 | self._value = None 26 | self._setter = None 27 | -------------------------------------------------------------------------------- /layoutx/widgets/radiobutton.py: -------------------------------------------------------------------------------- 1 | from .widget import Widget 2 | from tkinter import ttk, StringVar 3 | 4 | class RadioButton(Widget): 5 | def __init__(self, master, **kwargs): 6 | self._v = StringVar() 7 | super().__init__( 8 | tk=ttk.Radiobutton( 9 | master, 10 | variable=self._v), **kwargs 11 | ) 12 | self._setter = self.connect_to_prop("value", self.on_changed_value) 13 | self.connect_to_prop("sel", self.on_changed_sel) 14 | self._trace = self._v.trace_add("write", 15 | lambda *_: self._setter(self._v.get()) 16 | ) 17 | 18 | def on_changed_value(self, value): 19 | self._v.set(value) 20 | 21 | def on_changed_sel(self, value): 22 | self._tk.config(value=value) 23 | 24 | def on_disposed(self): 25 | self._v.trace_remove("write", self._trace) 26 | self._trace = None 27 | self._setter = None -------------------------------------------------------------------------------- /layoutx/widgets/splitpane.py: -------------------------------------------------------------------------------- 1 | # Modified by me 2 | from .widget import Widget 3 | from tkinter import ttk 4 | from tkinter.constants import HORIZONTAL, VERTICAL 5 | 6 | 7 | class SplitPane(Widget): 8 | def __init__(self, master, **kwargs): 9 | super().__init__( 10 | tk=ttk.PanedWindow( 11 | orient=kwargs.get("node").get_attr("orient", HORIZONTAL), 12 | master=master), **kwargs) 13 | 14 | def forget_children(self): 15 | for child in self.tk.panes(): 16 | self.tk.forget(child) 17 | 18 | def place_children(self, changed_value=None): 19 | self.forget_children() 20 | 21 | index = 0 22 | for child in self.children: 23 | if child and not child.hidden: 24 | self.tk.add(child.tk) 25 | self.tk.pane(index, **{ 26 | "weight": int(self.get_attr("weight", "1")) 27 | }) 28 | index += 1 29 | 30 | child._node.placed() -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: LayoutX, never write GUI spaghetti code again 2 | nav: 3 | - Introduction: index.md 4 | - Architecture: architecture.md 5 | - Layout Syntax: layout.md 6 | - View: view.md 7 | - Store: store.md 8 | - Widgets: widgets.md 9 | - Advanced Guide: advanced.md 10 | - Demos: showcase.md 11 | docs_dir: src-docs 12 | site_dir: public 13 | theme: 14 | favicon: 'img/favicon.ico' 15 | name: 'material' 16 | palette: 17 | primary: 'deep orange' 18 | accent: 'red' 19 | plugins: 20 | - search 21 | markdown_extensions: 22 | - admonition 23 | - footnotes 24 | - codehilite: 25 | guess_lang: false 26 | linenums: true 27 | - toc: 28 | permalink: true 29 | - plantuml_markdown: 30 | server: http://www.plantuml.com/plantuml # PlantUML server, for remote rendering 31 | - pymdownx.snippets: 32 | base_path: src-docs 33 | - pymdownx.inlinehilite: 34 | - pymdownx.superfences: 35 | - pymdownx.tasklist: 36 | custom_checkbox: true 37 | - pymdownx.details -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Pascal Maximilian Bremer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /layoutx/widgets/combobox.py: -------------------------------------------------------------------------------- 1 | from .widget import Widget 2 | from tkinter import ttk, StringVar 3 | from ttkwidgets.autocomplete import AutocompleteCombobox 4 | 5 | 6 | class ComboBox(Widget): 7 | def __init__(self, master, **kwargs): 8 | self._textv = StringVar() 9 | super().__init__( 10 | tk=AutocompleteCombobox( 11 | master=master, 12 | textvariable=self._textv 13 | ),**kwargs 14 | ) 15 | 16 | self._setter = self.connect_to_prop("value", self.on_changed_value) 17 | self._trace = self._textv.trace_add("write", 18 | lambda *_: self._setter(self._textv.get()) 19 | ) 20 | self.connect_to_prop("suggestion", self.on_changed_suggestion) 21 | 22 | def on_changed_suggestion(self, value): 23 | if self._textv.get() == None or self._textv.get() == "": 24 | if value and len(value) > 0: 25 | self._setter(value[0]) 26 | self._tk.set_completion_list(value if value else []) 27 | 28 | def on_changed_value(self, value): 29 | self._textv.set(value) 30 | 31 | def on_disposed(self): 32 | self._textv.trace_remove("write", self._trace) 33 | self._setter = None 34 | -------------------------------------------------------------------------------- /src-docs/store.md: -------------------------------------------------------------------------------- 1 | The store is powered by RxPY. If you want to get a deeper look, see [official documentation](https://rxpy.readthedocs.io/en/latest/). 2 | 3 | ## Reducers 4 | 5 | When creating the store you can additionally assign some reducers. 6 | 7 | ``` python 8 | def set_name(state, payload): 9 | return {**state, **{"name": payload}} 10 | 11 | reducers = { 12 | SET_NAME: set_name 13 | } 14 | 15 | store = create_store(reducers, { "name": "" }) 16 | ``` 17 | 18 | Then you can call the reducers via dispatch function: 19 | 20 | ``` python 21 | # dispatch(REDUCER_NAME, PAYLOAD) 22 | store.dispatch("SET_NAME", "My name") 23 | ``` 24 | 25 | ## Subscribe to data changes 26 | You can subscribe on store changes: 27 | 28 | ``` python 29 | def on_change(value): 30 | print(value) 31 | 32 | observer = store.subscribe(on_change) 33 | observer.dispose() # Stop watching 34 | ``` 35 | 36 | ## Watch a property 37 | 38 | ``` python 39 | data = { "users" : [ { "name": "Test" } ] } 40 | 41 | # Watch first user name 42 | def on_first_user_name_changed(value): 43 | print(value) 44 | 45 | observer = store.select_by_path("users.0.name") 46 | observer.subscribe(on_first_user_name_changed) 47 | observer.dispose() # stop watching 48 | ``` -------------------------------------------------------------------------------- /layoutx/widgets/tree.py: -------------------------------------------------------------------------------- 1 | from .widget import Widget 2 | from tkinter import ttk 3 | 4 | 5 | class TreeDisplay(Widget): 6 | def __init__(self, master, **kwargs): 7 | super().__init__( 8 | tk=ttk.Treeview(master), **kwargs) 9 | 10 | self.connect_to_prop("value", self._on_value_changed) 11 | self.connect_to_prop("header", self._on_header_changed) 12 | 13 | def _clear(self): 14 | self._tk.delete(*self._tk.get_children()) 15 | 16 | def _set_children(self, treeNode, value): 17 | for i, item in enumerate(value): 18 | childNode = self._tk.insert(treeNode, 'end', text=item["text"], values=item["values"]) 19 | if "children" in item: 20 | self._set_children(childNode, item["children"]) 21 | 22 | def _on_header_changed(self, value): 23 | self._tk["columns"]= list(k for k in value.keys() if k != "#0") 24 | 25 | for key, v in value.items(): 26 | if "column" in v: 27 | self._tk.column(key, **v["column"]) 28 | if "heading" in v: 29 | self._tk.heading(key, **v["heading"]) 30 | 31 | def _on_value_changed(self, value): 32 | self._clear() 33 | 34 | for item in value: 35 | treeNode = self._tk.insert('', 'end', text=item["text"], values=item["values"], open=False) 36 | if "children" in item: 37 | self._set_children(treeNode, item["children"]) -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | stages: 2 | - test 3 | - deploy 4 | 5 | .test: 6 | script: 7 | - "apk add --update alpine-sdk git xvfb python3-tkinter build-base zlib-dev jpeg-dev" 8 | - "exec Xvfb :8 -screen 0 640x480x24 2>/tmp/Xvfb.log &" 9 | - "python -m venv /opt/venv" 10 | - "source /opt/venv/bin/activate; pip install --upgrade pip" 11 | - 'source /opt/venv/bin/activate; DISPLAY=:8 pip install -e ".[more_widgets,styles]"' 12 | - "source /opt/venv/bin/activate; DISPLAY=:8 python -m layoutx.install_tkdnd" 13 | - "source /opt/venv/bin/activate; DISPLAY=:8 python setup.py test" 14 | 15 | test-3.7: 16 | extends: ".test" 17 | stage: "test" 18 | image: "python:3.7-alpine" 19 | 20 | test-3.8: 21 | extends: ".test" 22 | stage: "test" 23 | image: "python:3.8-alpine" 24 | 25 | pages: 26 | image: python:3.8-alpine 27 | script: 28 | - "apk add --update alpine-sdk" 29 | - pip install -r requirements-docs.txt 30 | - mkdocs build 31 | artifacts: 32 | paths: 33 | - public 34 | stage: deploy 35 | only: 36 | - master 37 | 38 | pypi: 39 | image: python:3 40 | stage: deploy 41 | cache: {} 42 | script: 43 | - pip install -U twine 44 | - python setup.py check sdist # This will fail if your creds are bad. 45 | - python setup.py sdist 46 | - twine upload dist/* 47 | only: 48 | - tags -------------------------------------------------------------------------------- /layoutx/widgets/formitem.py: -------------------------------------------------------------------------------- 1 | from .widget import Widget 2 | from .label import Label 3 | from .input import Input 4 | from tkinter import ttk 5 | from copy import deepcopy 6 | 7 | 8 | class FormItem(Container): 9 | def __init__(self, **kwargs): 10 | self._tk = ttk.Frame( 11 | master=kwargs.get("master").container) 12 | 13 | kwargs.get("node").attrib["orient"] = "horizontal" 14 | super().__init__(**kwargs) 15 | 16 | label_node = deepcopy(self._node) 17 | label_node.tag = "Label" 18 | del label_node.attrib["value_type"] 19 | del label_node.attrib["orient"] 20 | 21 | self.init_child(label_node, self._path_mapping) 22 | 23 | value_type = self.get_attr("value_type", None) 24 | if value_type: 25 | self.on_changed_value_type(value_type) 26 | 27 | def on_changed_value_type(self, value): 28 | data_node = deepcopy(self._node) 29 | del data_node.attrib["value_type"] 30 | del data_node.attrib["orient"] 31 | 32 | if value == "Input": 33 | data_node.tag = "Input" 34 | else: 35 | return 36 | 37 | if self.children_count == 2: 38 | child = self._children.pop(1) 39 | child.dispose() 40 | 41 | self.init_child(data_node, self._path_mapping) 42 | 43 | self.apply_children_grid() 44 | 45 | -------------------------------------------------------------------------------- /layoutx/widgets/__init__.py: -------------------------------------------------------------------------------- 1 | from .scroll_frame import ScrollFrame 2 | from .label import Label 3 | from .button import Button 4 | from .box import Box 5 | from .drop_target import DropTarget 6 | from .seperator import Sep 7 | from .widget import Widget 8 | from .splitpane import SplitPane 9 | from .checkbox import CheckBox 10 | from .progressbar import ProgressBar 11 | from .spinbox import SpinBox 12 | from .sheet import Sheet 13 | from .radiobutton import RadioButton 14 | from .notebook import Notebook 15 | from .calendar import CalendarWidget 16 | from .imageviewer import ImageViewer 17 | from .tree import TreeDisplay 18 | from .htmlview import HTMLLabel, HTMLScrolledText, HTMLText 19 | 20 | __all__ = [ 21 | "Widget", "SplitPane", "Label", 22 | "Box", "ScrollFrame", "Button", "DropTarget", "CalendarWidget", 23 | "Sep", "CheckBox", "ProgressBar", "SpinBox", "Sheet", "RadioButton", "Notebook", 24 | "ImageViewer", "TreeDisplay", "HTMLLabel", "HTMLScrolledText", "HTMLText" 25 | ] 26 | 27 | try: 28 | from .input import Input, FileInput 29 | from .combobox import ComboBox 30 | from .textarea import TextArea 31 | from .scale import Scale 32 | 33 | __all__ = __all__ + [ "FileInput", "Input", "ComboBox", "TextArea", "Scale" ] 34 | except ImportError: 35 | # ttkwidgets not installed 36 | pass -------------------------------------------------------------------------------- /tests/test_parser.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from layoutx._parser import parse_pug_to_obj, XMLElement 3 | 4 | 5 | class TestParser(unittest.TestCase): 6 | 7 | def test_simple(self): 8 | template = \ 9 | """ 10 | Button Hello world 11 | """ 12 | parsed = parse_pug_to_obj(template) 13 | assert issubclass(parsed.__class__, XMLElement) 14 | assert parsed.tag == "Button" 15 | assert parsed.text == "Hello world" 16 | 17 | 18 | def test_children(self): 19 | template = \ 20 | """ 21 | Parent 22 | Child 1 23 | Child 2 24 | """ 25 | parsed = parse_pug_to_obj(template) 26 | assert parsed.tag == "Parent" 27 | assert parsed.count_children == 2 28 | assert parsed.children[0].text == '1' 29 | assert parsed.children[1].text == '2' 30 | 31 | def test_attr(self): 32 | template = \ 33 | """ 34 | Parent 35 | Child(string="attr_a" int=123 obj={'key': 'value'} bool=False) 36 | """ 37 | parsed = parse_pug_to_obj(template) 38 | assert parsed.tag == "Parent" 39 | child = parsed.children[0] 40 | assert child.tag == "Child" 41 | assert child.get_attribute("none", None) == None 42 | assert child.get_attribute("string") == "attr_a" 43 | assert child.get_attribute("int") == 123 44 | assert child.get_attribute("obj") == {'key': 'value'} 45 | assert child.get_attribute("bool") == False 46 | 47 | 48 | if __name__ == '__main__': 49 | unittest.main() -------------------------------------------------------------------------------- /layoutx/widgets/scale.py: -------------------------------------------------------------------------------- 1 | from .widget import Widget 2 | from tkinter import IntVar, ttk 3 | from ttkwidgets import TickScale 4 | 5 | class TkTickScale(ttk.Frame): 6 | def __init__(self, master, orient, **kwargs): 7 | super().__init__(master) 8 | self.scale = TickScale(self, orient=orient, **kwargs) 9 | self.orient = orient 10 | if orient == "horizontal": 11 | self.scale.pack(fill="x") 12 | else: 13 | self.scale.pack(fill="y") 14 | 15 | def configure(self, **kwargs): 16 | self.scale.configure(**kwargs) 17 | 18 | def cget(self, name): 19 | return self.scale.cget(name) 20 | 21 | def keys(self): 22 | return self.scale.keys() 23 | 24 | def winfo_class(self): 25 | return f"{self.orient.capitalize()}.TScale" 26 | 27 | class Scale(Widget): 28 | def __init__(self, master, **kwargs): 29 | self._valuev = IntVar() 30 | super().__init__(tk=TkTickScale( 31 | master=master, 32 | orient=kwargs.get("node").get_attr("orient","horizontal"), 33 | variable=self._valuev), **kwargs) 34 | self._setter = self.connect_to_prop("value", self.on_changed_value) 35 | self._trace = self._valuev.trace_add("write", 36 | lambda *_: self._setter(self._valuev.get()) 37 | ) 38 | 39 | def on_changed_value(self, value): 40 | self._valuev.set(int(value)) 41 | 42 | def on_disposed(self): 43 | self._valuev.trace_remove("write", self._trace) 44 | self._valuev = None 45 | self._setter = None -------------------------------------------------------------------------------- /demo/cat_facts.py: -------------------------------------------------------------------------------- 1 | from layoutx import app 2 | from layoutx.store import create_store 3 | from layoutx.view import View, ResizeOption 4 | 5 | store = create_store({}, { 6 | "facts": [], 7 | "loading": False 8 | }) 9 | 10 | class LoadFacts(View): 11 | geometry = "800x600+200+200" 12 | title = "FactLoader" 13 | resizable = ResizeOption.BOTH 14 | template = """\ 15 | ScrollFrame 16 | Label(if="{loading}") Loading, please wait ... 17 | Button(command="{load_facts}") load facts 18 | Box(for="{fact in facts if fact.type == 'cat'}") 19 | Box(orient="horizontal") 20 | Label(background="{'grey' if fact.deleted else 'green'}") {fact.text} 21 | """ 22 | 23 | async def load_facts(self): 24 | self.store.dispatch("SET_VALUE", { 25 | "path": ["loading"], 26 | "value": True 27 | }) 28 | import aiohttp 29 | session = aiohttp.ClientSession() 30 | facts_list = [] 31 | async with session.get("https://cat-fact.herokuapp.com/facts/random?animal_type=horse&amount=5") as facts: 32 | facts_list += await facts.json() 33 | 34 | async with session.get("https://cat-fact.herokuapp.com/facts/random?animal_type=cat&amount=5") as facts: 35 | facts_list += await facts.json() 36 | 37 | await session.close() 38 | 39 | self.store.dispatch("SET_VALUE", { 40 | "path": ["loading"], 41 | "value": False 42 | }) 43 | 44 | self.store.dispatch("SET_VALUE", { 45 | "path": ["facts"], 46 | "value": facts_list 47 | }) 48 | 49 | if __name__ == "__main__": 50 | app.setup(store=store, rootView=LoadFacts) 51 | app.run() -------------------------------------------------------------------------------- /src-docs/view.md: -------------------------------------------------------------------------------- 1 | The view can be seen as the controller in the classical MVC aspect. The widget can call the views methods. 2 | 3 | !!! note 4 | If you want to call a view method with a parameter from the store, use the **partial** method. 5 | 6 | 7 | ## Methods 8 | Any method inside the view is callable from a widget: 9 | 10 | ```python tab="View" 11 | class MyView(View): 12 | def print_greeting(self, value): 13 | print(value) 14 | 15 | ``` 16 | 17 | ```pug tab="Layout" 18 | Button(command="{partial(print_greeting,'Hello World')}") 19 | ``` 20 | 21 | ## Adding a menu 22 | To set a topbar menu in the window, overwrite the **set_menu** method. 23 | 24 | 25 | ```python 26 | class MyView(View): 27 | def set_menu(self): 28 | return { 29 | "Print": self.print_greeting 30 | } 31 | 32 | def print_greeting(self): 33 | print("menu_called") 34 | 35 | ``` 36 | 37 | ## Find child widget 38 | 39 | All Widgets and Views can search itself for corresponding children. 40 | 41 | You can search via widget type (put !-Symbol before the type) or widget name. 42 | 43 | Additionally you can use the wildcard symbol (*) to search for nested widgets 44 | 45 | ``` python 46 | 47 | view.find_by_name("NamedWidget") # named widget 48 | view.find_all("!Button") # all buttons 49 | view.find_all("!Box.*.!Button") # all buttons that are part of a Box Widget 50 | view.find_first("!Button") # return first button you can find 51 | 52 | # If you want to search all views, use the app singleton 53 | 54 | from layoutx import app 55 | 56 | app.find_all("!Label") 57 | 58 | ``` 59 | 60 | ## Properties 61 | 62 | | Name | Description | 63 | | --- | ------------------- | 64 | | store | Reference to data store | -------------------------------------------------------------------------------- /src-docs/architecture.md: -------------------------------------------------------------------------------- 1 | ```plantuml format="png" classes="uml" alt="Architecture" title="Architecture" 2 | 3 | @startuml 4 | 5 | skinparam component { 6 | FontSize 13 7 | BackgroundColor<> Red 8 | BorderColor<> #FF6655 9 | FontName Courier 10 | BorderColor black 11 | BackgroundColor gold 12 | ArrowFontName Impact 13 | ArrowColor #FF6655 14 | ArrowFontColor #777777 15 | } 16 | 17 | Application - [Registry] 18 | Layout ..> [View] 19 | [Registry] <..> [View] 20 | [View] <..> () Store 21 | [View] <..> [Widget] 22 | [Widget] ..> [Widget] 23 | 24 | @enduml 25 | 26 | ``` 27 | 28 | ## Application 29 | 30 | The Application is a singleton and the single entry to the framework itself. It is mostly used to initially setup the framework. 31 | 32 | ## Registry 33 | 34 | The Registry is an internal data tree and keeps the parent-child information of each view and their connected widgets. It is managed by the views. 35 | 36 | ## View 37 | 38 | A View represents a GUI window. It can have its own store or uses the application default store. Each view has it own layout declaration and dynamically creates its widgets according to it. 39 | 40 | ## Widget 41 | 42 | A widget extends the standard tkinter widget with additional capabilities like initializing two-way binding with the store. It holds a reference to the Application, parent view and store. 43 | Via lifecycle methods the behavior of a widget at runtime can be controlled. 44 | 45 | ## Store 46 | 47 | The store keeps all business data at a centralized place. It is powered by RxPY and emulates the Redux pattern. You can define reducers to simplify the data manipulation. All connected widgets are automatically informed about any changes via subscriptions. 48 | 49 | ## Layout 50 | 51 | Written in Pug, it is the declarative representation of the UI and holds all display releated information. -------------------------------------------------------------------------------- /demo/todo_list.py: -------------------------------------------------------------------------------- 1 | from layoutx import app 2 | from layoutx.store import create_store 3 | from layoutx.view import View, ResizeOption 4 | from uuid import uuid4 5 | 6 | store = create_store({ 7 | "DELETE_TODO": lambda state, payload: {**state, **{"todos": list(filter(lambda x: x["id"] != payload, state["todos"])) }}, 8 | "ADD_TODO": lambda state, *_: {**state, **{"todos": state["todos"] + [{ "id": uuid4(), "text": "Edit me" }] }} 9 | }, { 10 | "todos": [], 11 | "selected": -1 12 | }) 13 | 14 | 15 | class ChangeToDoText(View): 16 | geometry = "300x50+500+500" 17 | title = "Change Text" 18 | resizable = ResizeOption.NONE 19 | template = """\ 20 | Box Edit Todo: {todos[selected].id} 21 | Input(value="{{todos[selected].text}}") 22 | """ 23 | 24 | class ToDoList(View): 25 | geometry = "500x100+200+200" 26 | title = "ToDoList" 27 | resizable = ResizeOption.BOTH 28 | template = """\ 29 | Box 30 | ScrollFrame To-Do\'s 31 | Box(orient="vertical" for="{todo in todos}") 32 | Box(orient="horizontal") 33 | Label {todo.text} 34 | Button(weight="0" command="{partial(change_todo, todo.id)}") Edit 35 | Button(weight="0" command="{partial(DELETE_TODO, todo.id)}") Del 36 | Button(weight="0" command="{ADD_TODO}") Add Todo 37 | """ 38 | 39 | # private attributes 40 | _editView = None 41 | 42 | def change_todo(self, todo_id): 43 | list_id = next((i for i, x in enumerate(self.store.state["todos"]) if x["id"] == todo_id), -1) 44 | if list_id == -1: 45 | return 46 | 47 | self.store.dispatch("SET_VALUE", { 48 | "path": ["selected"], 49 | "value": list_id 50 | }) 51 | if not self._editView or self._editView._tk.winfo_exists() == 0: 52 | self._editView = app.add_view( ChangeToDoText(store=store) ).widget 53 | self._editView.show() 54 | 55 | if __name__ == "__main__": 56 | app.setup(store=store, rootView=ToDoList) 57 | app.run() -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | parts/ 18 | sdist/ 19 | var/ 20 | wheels/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | MANIFEST 25 | 26 | # PyInstaller 27 | # Usually these files are written by a python script from a template 28 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 29 | *.manifest 30 | *.spec 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .coverage 40 | .coverage.* 41 | .cache 42 | nosetests.xml 43 | coverage.xml 44 | *.cover 45 | .hypothesis/ 46 | .pytest_cache/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | db.sqlite3 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # Environments 83 | .env 84 | .venv 85 | env/ 86 | venv/ 87 | ENV/ 88 | env.bak/ 89 | venv.bak/ 90 | 91 | # Spyder project settings 92 | .spyderproject 93 | .spyproject 94 | 95 | # Rope project settings 96 | .ropeproject 97 | 98 | # mkdocs documentation 99 | /site 100 | /docs 101 | 102 | # mypy 103 | .mypy_cache/ 104 | 105 | # testing 106 | testing/ 107 | .vscode/settings.json 108 | examples/ 109 | 110 | build/ 111 | dist/ -------------------------------------------------------------------------------- /demo/show_image.py: -------------------------------------------------------------------------------- 1 | from layoutx import app 2 | from layoutx.store import create_store 3 | from layoutx.view import View, ResizeOption 4 | 5 | store = create_store({ 6 | "SET_IMAGE": lambda state, payload: {**state, **{"data": payload}} 7 | }, { "data": "" }) 8 | 9 | class ImageShowcase(View): 10 | geometry = "800x600+200+200" 11 | title = "ImageViewer" 12 | resizable = ResizeOption.NONE 13 | template= """\ 14 | Box 15 | Label(weight="0") Image Viewer 16 | ImageViewer(name="image" background="black" imagedata="{data}") 17 | Button(weight="0" height="20" command="{load_image}") New Image 18 | """ 19 | 20 | async def load_image(self): 21 | # Find view child widget api not yet finalized 22 | imageViewer = self._widget.find_first("image") 23 | 24 | # Get tkinter attributes 25 | height = imageViewer.widget.tk.winfo_height() 26 | width = imageViewer.widget.tk.winfo_width() 27 | 28 | import aiohttp 29 | import io 30 | from random import randint 31 | 32 | imagedata = None 33 | session = aiohttp.ClientSession() 34 | async with session.get(f"http://placekitten.com/{width}/{height}?image={randint(0,17)}") as imageResource: 35 | from PIL import Image, ImageTk 36 | load = Image.open(io.BytesIO(await imageResource.read())) 37 | imagedata = ImageTk.PhotoImage(load) 38 | await session.close() 39 | self.store.dispatch("SET_IMAGE", imagedata) 40 | 41 | from layoutx.widgets import Widget 42 | from tkinter import ttk 43 | class ImageViewer(Widget): 44 | def __init__(self, master, **kwargs): 45 | super().__init__(tk=ttk.Label(master=master), **kwargs) 46 | self.connect_to_prop("imagedata", self.on_imagedata_changed) 47 | 48 | def on_imagedata_changed(self, imagedata): 49 | if imagedata == '': 50 | return 51 | self._tk.configure(image=imagedata) 52 | 53 | app.add_custom_widget("ImageViewer", ImageViewer) 54 | 55 | if __name__ == "__main__": 56 | app.setup(store=store, rootView=ImageShowcase) 57 | app.run() -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | from setuptools.command.develop import develop 3 | from setuptools.command.install import install 4 | #from install_tkdnd import dnd_installed, dnd_install 5 | from pathlib import Path 6 | 7 | min_version = "2.9.2" 8 | 9 | #def run_install(): 10 | # version = dnd_installed() 11 | # if not version or version < min_version: 12 | # dnd_install() 13 | 14 | class PreInstallCommand(install): 15 | """Post-installation for installation mode.""" 16 | 17 | def run(self): 18 | # run_install() 19 | install.run(self) 20 | 21 | class PreDevelopCommand(develop): 22 | """Post-installation for development mode.""" 23 | 24 | def run(self): 25 | # run_install() 26 | develop.run(self) 27 | 28 | with open("Readme.md", "r") as fh: 29 | long_description = fh.read() 30 | 31 | setuptools.setup( 32 | cmdclass={ 33 | 'install': PreInstallCommand, 34 | 'develop': PreDevelopCommand 35 | }, 36 | name='layoutx', 37 | entry_points={ 38 | 'console_scripts': [ 39 | 'lxdesigner = layoutx.tools.designer:main' 40 | ] 41 | }, 42 | version='1.2', 43 | author='Pascal Maximilian Bremer', 44 | description="Declarative tkinter layout engine with reactive data binding", 45 | long_description=long_description, 46 | long_description_content_type="text/markdown", 47 | url="https://github.com/Bomberus/LayoutX", 48 | packages=setuptools.find_packages(include=['layoutx', 'layoutx.*']), 49 | include_package_data=True, 50 | test_suite="tests", 51 | install_requires=[ 52 | "pypugjs", 53 | "rx", 54 | "tksheet" 55 | ], 56 | extras_require = { 57 | "more_widgets": ["ttkwidgets", "pygments"], 58 | "styles": ["ttkthemes"] 59 | }, 60 | classifiers=[ 61 | 'Development Status :: 3 - Alpha', 62 | 'Intended Audience :: Developers', 63 | 'License :: OSI Approved :: MIT License', 64 | 'Programming Language :: Python :: 3.8', 65 | 'Programming Language :: Python :: 3.7', 66 | 'Topic :: Software Development' 67 | ] 68 | ) -------------------------------------------------------------------------------- /tests/test_store.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import unittest.mock 3 | from layoutx.store import create_store 4 | 5 | 6 | class TestStore(unittest.TestCase): 7 | 8 | def test_simple(self): 9 | observer = unittest.mock.MagicMock() 10 | store= create_store({}, {"name": "Test"}) 11 | assert store.state == {"name": "Test"} 12 | 13 | store.subscribe(observer.subscribe) 14 | assert len(observer.method_calls) == 1 15 | 16 | store.dispatch("SET_VALUE", {"path": ["name"], "value": "Test2"}) 17 | assert len(observer.method_calls) == 2 18 | 19 | assert store.state == {"name": "Test2"} 20 | 21 | def test_dispose(self): 22 | store= create_store({}, {"name": "Test"}) 23 | 24 | mock = unittest.mock.MagicMock() 25 | 26 | observer = store.subscribe(mock) 27 | assert len(mock.method_calls) == 1 28 | 29 | observer.dispose() 30 | 31 | store.dispatch("SET_VALUE", {"path": ["name"], "value": "Test2"}) 32 | assert len(mock.method_calls) == 1 33 | 34 | def test_reducer(self): 35 | def name_reducer(state, payload): 36 | return {**state, **{ "name": payload }} 37 | 38 | store = create_store({ "NAME_REDUCER" : name_reducer }, {"name": "Test"}) 39 | store.dispatch("NAME_REDUCER", "Test2") 40 | 41 | assert store.state == {"name": "Test2"} 42 | 43 | def test_select(self): 44 | store = create_store({},{ 45 | "hosts": [{ 46 | "url": "1" 47 | },{ 48 | "url": "2" 49 | }] 50 | }) 51 | 52 | mock = unittest.mock.MagicMock() 53 | 54 | observer = store.select_by_path("hosts.1.url") 55 | observer.subscribe(mock) 56 | assert len(mock.method_calls ) == 1 57 | assert mock.method_calls[0][0] == "on_next" 58 | assert mock.method_calls[0][1][0] == "2" 59 | 60 | store.dispatch("SET_VALUE", {"path": ["hosts", "0", "url"], "value": "3"}) 61 | assert len(mock.method_calls ) == 1 62 | assert store.state == { "hosts": [{ "url": "3" },{ "url": "2" }] } 63 | 64 | 65 | def test_select_exp(self): 66 | pass 67 | 68 | if __name__ == '__main__': 69 | unittest.main() -------------------------------------------------------------------------------- /layoutx/widgets/logger.py: -------------------------------------------------------------------------------- 1 | from .widget import Widget 2 | import tkinter as tk 3 | from tkinter import ttk, END 4 | from tkinter.scrolledtext import ScrolledText 5 | import logging 6 | import re 7 | 8 | 9 | class Logger(Widget): 10 | class LoggingFilter(logging.Filter): 11 | def filter(self, record): 12 | return record.level == self._level and re.match(self._regex_filter, record.message) 13 | 14 | 15 | class Handler(logging.Handler): 16 | def __init__(self, widget): 17 | logging.Handler.__init__(self) 18 | self.setFormatter(logging.Formatter("%(asctime)s: %(message)s")) 19 | self._tk = widget 20 | self._tk.config(state='disabled') 21 | 22 | def emit(self, record): 23 | self._tk.config(state='normal') 24 | if record.msg.startswith("INIT"): 25 | self._tk.insert(END, self.format(record) + "\n", "init") 26 | elif record.msg.startswith("DISPOSE"): 27 | self._tk.insert(END, self.format(record) + "\n", "dispose") 28 | else: 29 | self._tk.insert(END, self.format(record) + "\n") 30 | self._tk.see(END) 31 | self._tk.config(state='disabled') 32 | 33 | def __init__(self, **kwargs): 34 | self._tk = ScrolledText(master=kwargs.get("master").container) 35 | Widget.__init__(self, **kwargs) 36 | 37 | self.logging_handler = Logger.Handler(self._tk) 38 | 39 | self._logger.addHandler(self.logging_handler) 40 | self._level = self.get_attr("level", "DEBUG") 41 | self._regex_filter = self.get_attr("filter", ".*") 42 | 43 | def on_changed_level(self, value): 44 | self._level = value 45 | self._setFilters() 46 | 47 | def on_changed_filter(self, value): 48 | self._regex_filter = value 49 | self._setFilters() 50 | 51 | def clear(self): 52 | self._tk.delete("1.0", tk.END) 53 | 54 | def _setFilters(self): 55 | for log_filter in self.logging_handler.filters: 56 | self.logging_handler.removeFilter(log_filter) 57 | 58 | self.logging_handler.addFilter(LoggingFilter) 59 | 60 | def dispose(self): 61 | self._logger.removeHandler(self.logging_handler) 62 | super().dispose() 63 | -------------------------------------------------------------------------------- /tests/test_expr_compile.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from layoutx.utils import compile_exp, eval_compiled, set_state 3 | 4 | class TestExpCompiler(unittest.TestCase): 5 | 6 | def test_security(self): 7 | try: 8 | compile_exp("name._internal_") 9 | assert False, "Exp should not compile" 10 | except ValueError as e: 11 | assert str(e) == "Internal Name cannot be used!" 12 | 13 | # Allowed names 14 | try: 15 | compile_exp("_s.name", allowed_names=["_s"] ) 16 | assert True 17 | except: 18 | assert False, "Variable Names are possible" 19 | 20 | # Normal statement 21 | try: 22 | compile_exp("name.value") 23 | assert True 24 | except: 25 | assert False, "Exp should compile" 26 | 27 | def test_simple_expression(self): 28 | comp = compile_exp("1 + 2") 29 | assert eval_compiled(comp) == 3 30 | 31 | def test_path_mapping(self): 32 | state = { 33 | "artists": [{ 34 | "songs": [ 35 | { 36 | "name": "1" 37 | },{ 38 | "name": "2" 39 | } 40 | ] 41 | }] 42 | } 43 | 44 | import ast 45 | 46 | path_mapping = { 47 | "artist": ast.parse('_s.artists[0]').body[0].value, 48 | "single": ast.parse('artist.songs[0]').body[0].value, 49 | "singleName": ast.parse('single.name').body[0].value 50 | } 51 | 52 | comp = compile_exp("singleName", path_mapping=path_mapping, allowed_names=["_s"] ) 53 | assert eval_compiled(comp, variables={"_s": state}) == "1" 54 | 55 | def test_set_state(self): 56 | state = { 57 | "artists": [{ 58 | "songs": [ 59 | { 60 | "name": "1" 61 | },{ 62 | "name": "2" 63 | } 64 | ] 65 | }] 66 | } 67 | 68 | variables={"_s": state} 69 | 70 | import ast 71 | 72 | path_mapping = { 73 | "artist": ast.parse('_s.artists[0]').body[0].value, 74 | "single": ast.parse('artist.songs[0]').body[0].value, 75 | "singleName": ast.parse('single.name').body[0].value 76 | } 77 | 78 | comp = compile_exp("singleName", path_mapping=path_mapping, allowed_names=["_s"], mode="exec") 79 | set_state(comp, variables, "New") 80 | assert state["artists"][0]["songs"][0]["name"] == "New", "State not set" 81 | 82 | if __name__ == '__main__': 83 | unittest.main() -------------------------------------------------------------------------------- /layoutx/widgets/sheet.py: -------------------------------------------------------------------------------- 1 | from .widget import Widget 2 | from tksheet import Sheet as tkSheet 3 | 4 | class Sheet(Widget): 5 | def __init__(self, master,**kwargs): 6 | self._tksheet = tkSheet(master) 7 | super().__init__(tk=self._tksheet, **kwargs) 8 | 9 | self.connect_to_prop("theme", self._on_theme_changed) 10 | self.connect_to_prop("headers", self._on_headers_changed) 11 | self._setter = self.connect_to_prop("value", self._on_value_changed) 12 | self.connect_to_prop("highlight", self._on_highlight_changed) 13 | self.connect_to_prop("editable", self._on_editable_changed) 14 | 15 | self._tksheet.extra_bindings([ 16 | ("edit_cell", self._on_data_edited), 17 | ("rc_delete_row", self._on_data_edited), 18 | ("rc_delete_column", self._on_data_edited), 19 | ("rc_insert_column", self._on_data_edited), 20 | ("rc_insert_row", self._on_data_edited), 21 | ("ctrl_z", self._on_data_edited), 22 | ("delete_key", self._on_data_edited), 23 | ("ctrl_v", self._on_data_edited), 24 | ("ctrl_x", self._on_data_edited) 25 | ]) 26 | 27 | self._editable = True 28 | self._tksheet.enable_bindings(( 29 | "single_select", 30 | "copy", 31 | "cut", 32 | "paste", 33 | "delete", 34 | "undo", 35 | "edit_cell", 36 | "column_width_resize" 37 | )) 38 | 39 | def _on_data_edited(self, *_): 40 | self._setter(self._tksheet.get_sheet_data(return_copy=True)) 41 | 42 | def _on_theme_changed(self, value): 43 | self._tksheet.change_theme(value) 44 | 45 | def _on_headers_changed(self, value): 46 | self._tksheet.headers(value) 47 | 48 | def _on_highlight_changed(self): 49 | #self.sheet.highlight_cells(row = 5, column = 5, bg = "#ed4337", fg = "white") 50 | #self.sheet.dehighlight_cells(self, row = 0, column = 0, cells = [], canvas = "table", all_ = False, redraw = True): 51 | #self.sheet.get_highlighted_cells 52 | pass 53 | 54 | def _on_value_changed(self, value): 55 | self._tksheet.set_sheet_data(value) 56 | 57 | def _on_editable_changed(self, value): 58 | self._editable = value 59 | self._tksheet.disable_bindings() 60 | if (self._editable): 61 | self._tksheet.enable_bindings( 62 | ["single_select", 63 | "copy", 64 | "cut", 65 | "paste", 66 | "delete", 67 | "undo", 68 | "edit_cell"] 69 | ) 70 | else: 71 | self._tksheet.enable_bindings( 72 | "single_select", 73 | "copy", 74 | "cut", 75 | "paste", 76 | "delete", 77 | "undo", 78 | "edit_cell" 79 | ) 80 | 81 | def on_disposed(self): 82 | self._tksheet = None -------------------------------------------------------------------------------- /layoutx/widgets/drop_target.py: -------------------------------------------------------------------------------- 1 | from .widget import Widget 2 | import tkinter as tk 3 | from ..tkDnD import DND_FILES 4 | from asyncio import iscoroutine 5 | 6 | class DropTarget(Widget): 7 | def __init__(self, master, **kwargs): 8 | self._cmd_drop_begin = None 9 | self._cmd_drop = None 10 | self._cmd_drop_end = None 11 | super().__init__(tk=tk.Canvas( 12 | master = master 13 | ), **kwargs) 14 | self._redraw = lambda *_: self._draw("Drop here", "blue") 15 | self._tk.bind( "", self._redraw) 16 | self._tk.drop_target_register(DND_FILES) 17 | self._tk.dnd_bind('<>', self._drop_begin) 18 | self._tk.dnd_bind('<>', self._drop_end) 19 | self._tk.dnd_bind('<>', self._drop) 20 | self._tk.bind("", self._open_file) 21 | self._draw("Drop here", "blue") 22 | 23 | self.connect_to_prop("on_drop_begin", self._on_drop_begin_command) 24 | self.connect_to_prop("on_drop", self._on_drop_command) 25 | self.connect_to_prop("on_drop_end", self._on_drop_end_command) 26 | 27 | def _on_drop_begin_command(self, value): 28 | pass 29 | 30 | def _on_drop_command(self, value): 31 | self._cmd_drop = value 32 | 33 | def _on_drop_end_command(self, value): 34 | pass 35 | 36 | def _draw(self, text: str, color: str): 37 | width = self._tk.winfo_width() 38 | height = self._tk.winfo_height() 39 | 40 | self._tk.delete("all") 41 | self._tk.create_rectangle(0, 0, width, height, fill=color) 42 | self._tk.create_text(width / 2 - 10, height / 2 - 4, 43 | anchor="center", 44 | font=("Purisa", 16), 45 | text=text, 46 | fill="white" 47 | ) 48 | 49 | def _drop_begin(self, event): 50 | self._draw("Release mouse to add File", "orange") 51 | 52 | def _drop_end(self, event): 53 | self._draw("GIMME FILE!!!", "red") 54 | 55 | def _open_file(self, event): 56 | f = tk.filedialog.askopenfilename(filetypes=[('Any File', '.*')]) 57 | if f is None or f == '': 58 | return 59 | self._call_cb(f) 60 | 61 | def _call_cb(self, path): 62 | try: 63 | cb = self._cmd_drop(**{ "path": path }) 64 | 65 | if cb and iscoroutine(cb): 66 | self._node.app.loop.create_task(cb) 67 | finally: 68 | pass 69 | 70 | def _drop(self, event): 71 | self._draw("Drop here", "blue") 72 | if event.data and self._cmd_drop: 73 | files = self._tk.tk.splitlist(event.data) 74 | for f in files: 75 | self._call_cb(f) 76 | return event.action 77 | 78 | def on_disposed(self): 79 | self._tk.unbind("", self._redraw) 80 | self._tk.unbind("", self._open_file) -------------------------------------------------------------------------------- /layoutx/install_tkdnd.py: -------------------------------------------------------------------------------- 1 | import platform 2 | import urllib.request 3 | import io 4 | import subprocess 5 | import pathlib 6 | import shutil 7 | import hashlib 8 | import time 9 | 10 | 11 | def dnd_installed(): 12 | try: 13 | import tkinter 14 | tk_root = tkinter.Tk() 15 | version = tk_root.tk.call('package', 'require', 'tkdnd') 16 | tk_root.tk.call('package', 'forget', 'tkdnd') 17 | tk_root.destroy() 18 | tk_root.tk = None 19 | tk_root = None 20 | 21 | return version 22 | except: 23 | return False 24 | 25 | def dnd_install(): 26 | urls = { 27 | "Windows" : "https://github.com/petasis/tkdnd/releases/download/tkdnd-release-test-v2.9.2/tkdnd-2.9.2-windows-x64.zip", 28 | "Linux" : "https://github.com/petasis/tkdnd/releases/download/tkdnd-release-test-v2.9.2/tkdnd-2.9.2-linux-x64.tgz", 29 | "Darwin" : "https://github.com/petasis/tkdnd/releases/download/tkdnd-release-test-v2.9.2/tkdnd-2.9.2-osx-x64.tgz" 30 | } 31 | 32 | hashes = { 33 | "Windows" : "d78007d93d8886629554422de2e89f64842ac9994d226eab7732cc4b59d1feea", 34 | "Linux" : "f0e956e4b0d62d4c7e88dacde3a9857e7a303dc36406bdd4d33d6459029a2843", 35 | "Darwin" : "0c604fb5776371e59f4c641de54ea65f24917b8e539a577484a94d2f66f6e31d" 36 | } 37 | 38 | print("Starting installation of tkDND") 39 | 40 | os = platform.system() 41 | 42 | if os not in ["Windows", "Linux", "Darwin"]: 43 | print(f"{os} not supported!") 44 | exit(0) 45 | 46 | result = None 47 | archive = None 48 | url = urls[os] 49 | download_hash = hashes[os] 50 | 51 | import tkinter 52 | root = tkinter.Tk() 53 | tcl_dir = pathlib.Path(root.tk.exprstring('$tcl_library')) 54 | 55 | for p in tcl_dir.glob("tkdnd*"): 56 | print("tkdnd already installed") 57 | shutil.rmtree(p) 58 | 59 | print("Download tkDnD libraries from github") 60 | 61 | data =urllib.request.urlopen(url).read() 62 | 63 | data_hash = hashlib.sha256(data).hexdigest() 64 | 65 | if (download_hash != data_hash): 66 | print(f"Got hash: {data_hash}") 67 | print(f"Expected hash: {download_hash}") 68 | print("Download incomplete or your security is compromised!!!") 69 | exit(1) 70 | 71 | print("Extracting tkDnD to tcl extension folder") 72 | if os == "Windows": 73 | import zipfile 74 | archive = zipfile.ZipFile(io.BytesIO(data)) 75 | archive.extractall(path=tcl_dir) 76 | elif os == "Linux" or os == "Darwin": 77 | import tarfile 78 | archive = tarfile.open(fileobj=io.BytesIO(data)) 79 | archive.extractall(path=tcl_dir) 80 | 81 | print("tkdnd installed!") 82 | 83 | if __name__ == "__main__": 84 | min_version = "2.9.2" 85 | version = dnd_installed() 86 | if not version or version < min_version: 87 | dnd_install() -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # Declarative tkinter UI with reactive data binding 2 | 3 | ![Banner](src-docs/img/Banner_no_bg.png) 4 | 5 | # Features 6 | - Drag and Drop support (tkDnD) 7 | - Supports async by default (powered by asyncio) 8 | - Two-way data binding 9 | - Flexible layout by design 10 | - Application scrolls automatically 11 | - Simple layout syntax powered by Pug (former Jade) 12 | - Widget parameter support inline python scripting 13 | - ttk themes included 14 | - Lightweight and fast 15 | - Add any custom tkinter widget 16 | 17 | ![Designer](src-docs/img/designer.GIF) 18 | 19 | # Installation 20 | Make sure your python installation is 3.7 or higher. 21 | 22 | You need the tk extension **tkDnD** for this framework. 23 | This can be automatically installed via pip argument. Make sure the python directory is writable (e.g. on Mac OSX, Python library are installed to /System/Library, which cannot be modified, even with sudo!). 24 | 25 | ```pip install layoutx``` (minimal version) 26 | 27 | ```pip install layoutx[more_widgets, styles]``` (full version) 28 | 29 | ```python -m layoutx.install_tkdnd``` (install tkdnd) 30 | 31 | The command line tool: `lxdesigner` can be used to easily design some forms. 32 | 33 | For lxdesigner to function, you need to install addon **more widgets**. 34 | 35 | # Addons 36 | Some dependencies of this project have a GPL v3 license. They are excluded into separate addons. 37 | Please note by installing these dependencies, you confirm to the GPL license agreement. 38 | 39 | This project itself is licensed under MIT. 40 | 41 | ## more widgets: 42 | More information see: [ttkwidgets Github](https://github.com/TkinterEP/ttkwidgets/) 43 | 44 | - Input 45 | - ComboBox 46 | - TextArea 47 | - Scale 48 | 49 | ```pip install layoutx[more_widgets]``` 50 | 51 | ## ttk themes: 52 | More information see: [ttkthemes Github](https://github.com/TkinterEP/ttkthemes) 53 | 54 | ```pip install layoutx[styles]``` 55 | 56 | # Getting started 57 | 58 | ``` python 59 | from layoutx import app # Import the app singleton 60 | from layoutx.store import create_store 61 | from layoutx.view import View, ResizeOption 62 | 63 | store = create_store({}, { "name": "World" }) 64 | 65 | class RootView(View): 66 | geometry = "250x50+100+100" 67 | title = "My first app" 68 | resizable = ResizeOption.NONE 69 | template = """\ 70 | ScrollFrame 71 | Box(orient="horizontal") 72 | Label Hello 73 | Input(value="{{name}}") 74 | Button(command="{say_my_name}") Say my name! 75 | """ 76 | def say_my_name(self): 77 | print(self.store.state["name"]) 78 | 79 | if __name__ == "__main__": 80 | app.setup(store=store, rootView=RootView) 81 | app.run() 82 | ``` 83 | 84 | ![Getting Started](src-docs/img/index/getting-started.png) 85 | 86 | # Documentation 87 | 88 | Read the [documentation](https://bomberus.gitlab.io/LayoutX/) for more information. 89 | -------------------------------------------------------------------------------- /layoutx/store.py: -------------------------------------------------------------------------------- 1 | from rx.subject import BehaviorSubject 2 | from rx import operators as rxop 3 | from typing import Dict, Callable, List 4 | from functools import reduce, partial 5 | from copy import deepcopy 6 | from operator import itemgetter 7 | import logging 8 | from .utils import safe_get, safe_set, safer_eval, eval_compiled 9 | from traceback import format_exc 10 | 11 | __all__ = [ "Store", "create_store" ] 12 | 13 | def apply_middleware(middlewares: [Callable]): 14 | def createStore(*args, **kwargs): 15 | store = createStore(*args, **kwargs) 16 | store.dispatch() 17 | return createStore 18 | 19 | 20 | def set_value_reducer(state, payload): 21 | path, value = itemgetter("path", "value")(payload) 22 | 23 | return safe_set(state, path, value) 24 | 25 | 26 | class Store: 27 | def __init__(self, reducer: Dict, init_value: Dict): 28 | self._reducer = reducer 29 | #Default Setter 30 | if "SET_VALUE" in self._reducer: 31 | raise KeyError("Reducer name 'SET_VALUE' is reserved") 32 | self._reducer["SET_VALUE"] = set_value_reducer 33 | 34 | self._state = BehaviorSubject({}) 35 | self._state.on_next(init_value) 36 | 37 | def dispatch(self, name: str, payload: Dict = None): 38 | if name in self._reducer: 39 | self._state.on_next( 40 | self._reducer[name]( 41 | self.state, payload 42 | ) 43 | ) 44 | 45 | def get_reducers(self): 46 | return {name : partial(self.dispatch, name) for name in self._reducer} 47 | 48 | def select_compiled(self, comp, built_in=None, logger=None): 49 | def wrapper(state): 50 | built_in.update(state) 51 | try: 52 | return eval_compiled(comp, variables=built_in) 53 | except: 54 | # Could not evaluate, probably wrong binding 55 | if logger: 56 | logger.warn(f"Error could not evaluate binding") 57 | logger.warn(format_exc(limit=2)) 58 | return None 59 | return self.select([wrapper]) 60 | 61 | def select(self, selectors: List): 62 | if len(selectors) == 1 and callable(selectors[0]): 63 | return self._state.pipe( 64 | rxop.map(selectors[0]), 65 | rxop.distinct_until_changed() 66 | ) 67 | else: 68 | return self._state.pipe( 69 | rxop.map(lambda data: safe_get(data, selectors)), 70 | rxop.distinct_until_changed() 71 | ) 72 | 73 | def select_by_path(self, path): 74 | return self.select(path.split(".")) 75 | 76 | def subscribe(self, subscriber): 77 | return self._state.subscribe(subscriber) 78 | 79 | @property 80 | def state(self) -> Dict: 81 | return self._state.value 82 | 83 | def create_store(reducer: Dict, init_value: Dict, enhancer: Callable = None): 84 | store = Store(reducer, init_value) 85 | store = enhancer(store)(reducer, init_value) if callable(enhancer) else store 86 | store.dispatch("INIT") 87 | return store 88 | 89 | -------------------------------------------------------------------------------- /src-docs/advanced.md: -------------------------------------------------------------------------------- 1 | ## Creating your own widgets 2 | 3 | Create a new class, that inherits the layoutx **Widget** class. 4 | 5 | Import any tkinter widget and pass it to the **tk** attribute. 6 | 7 | If you want to listen to changes on a specific attribute, use **connect_to_prop** 8 | 9 | ```python 10 | from layoutx.widgets import Widget 11 | from tkinter import ttk 12 | class ImageViewer(Widget): 13 | def __init__(self, master, **kwargs): 14 | super().__init__(tk=ttk.Label(master=master), **kwargs) 15 | self.connect_to_prop("imagedata", self.on_imagedata_changed) 16 | 17 | def on_imagedata_changed(self, imagedata): 18 | if imagedata == '': 19 | return 20 | self._tk.configure(image=imagedata) 21 | 22 | app.add_custom_widget("ImageViewer", ImageViewer) 23 | ``` 24 | 25 | ## Create a subview 26 | 27 | ```python 28 | class RootView(View): 29 | ... 30 | _childView = None 31 | 32 | def create_child(self): 33 | class ChildView(View): 34 | geometry = "300x50+500+500" 35 | title = "Child Window" 36 | resizable = ResizeOption.NONE 37 | template = """\ 38 | Box Child Window 39 | """ 40 | 41 | # check if child exists 42 | if not self._childView or self._childView._tk.winfo_exists() == 0: 43 | self._childView = app.add_view( ChildView(store=store) ).widget 44 | self._childView.show() 45 | 46 | ``` 47 | 48 | ## Security aspects 49 | Please note to offer dynamic expressions, **eval** and **exec** are used. 50 | But in order to increase security the ast is parsed before executing. 51 | 52 | Some expression, like accessing private attributes like: `data.__class__` is prohibited by default. 53 | 54 | !!! danger 55 | Never dynamically create views from user input! 56 | 57 | ## Package your application 58 | As an example, this is how you would package the designer with pyinstaller. 59 | 60 | 1. `pip install pyinstaller` 61 | 2. `python -O -m PyInstaller --noconsole --icon="layoutx/resources/favicon.ico" --hidden-import="pkg_resources.py2_warn" layoutx\tools\designer.py` 62 | 3. Distribute dist/designer folder 63 | 64 | ## React to tkinter widget events 65 | You can add a method to a tkinter native event, by specifying a widget attribute with surrounding double points. Then specify a view method that should be called, when the event is triggered. 66 | 67 | ```pug 68 | Label(:Button-1:="{partial(print_hello, 'label clicked')}") {name} 69 | ``` 70 | 71 | ## Change default font 72 | The fonts in the project might look a bit off. By default the application picks the first font it finds on the system. You can manually specify a font via app setup. Make sure the font is installed on the system. 73 | 74 | ```python 75 | app.setup(store=store, rootView=RootView, font={"family": "roboto", "size": "14"}) 76 | ``` 77 | 78 | !!! note 79 | Some python distribution (like conda) do not include necessary font libraries in unix distribution. 80 | This might lead to missing anti-alias feature for fonts. -------------------------------------------------------------------------------- /src-docs/layout.md: -------------------------------------------------------------------------------- 1 | ## Basic databinding 2 | Generally almost any attribute the tkinter widget has can be overwritten using databinding. 3 | Additionally if you create own widgets, you can define additional attributes. 4 | 5 | When using expressions, you can use the view public methods, reducers and store data. 6 | Some python built in functions are enabled like: __list__ or __enumerate__. 7 | 8 | Dangerous functions like: __eval__ are blocked. 9 | 10 | Dict traversals are automatically converted to attributes: 11 | 12 | `data["id"]` => `data.id` 13 | 14 | ### Types 15 | There are 3 different types of data binding. 16 | 17 | #### Static 18 | If you just want to assign a static value to an attribute. 19 | 20 | ```pug 21 | 22 | Widget(static="123") 23 | ``` 24 | 25 | #### Expression 26 | You want to define a dynamic computed attribute: 27 | 28 | ```pug 29 | 30 | Widget(dyn="{1+2}") 31 | ``` 32 | 33 | #### TwoWay Binding 34 | Attribute is bound to field in the store, once attribute is updated, so will be the field in the store 35 | 36 | ```pug 37 | Widget(tw="{{name}}") 38 | ``` 39 | 40 | #### Widget Text 41 | Inside a widgets text, you can use dynamic expression by including it in curly brackets: 42 | 43 | ```pug 44 | 45 | Widget Text { 1 + 2 } 46 | ``` 47 | 48 | 49 | Additionally, all properties from the store are available by default. Instead of using the dict key access method, you can directly reference store properties as attributes. 50 | 51 | When writing texts, include the data you want to use in curly brackets, like f-strings. 52 | 53 | ``` pug tab="Layout" 54 | Label(background="orange") {name} World 55 | ``` 56 | 57 | ``` json tab="Data" 58 | { 59 | "name": "Hello" 60 | } 61 | ``` 62 | 63 | ![Basic](img/layout/basic.png) 64 | 65 | ## Inline scripting 66 | 67 | Would you like to change a certain behavior depending on the data in the store ? You can use inline python scripting, including data access. The scripting also gets reevaluated every time the data changes. 68 | 69 | ``` pug tab="Layout" 70 | Label(background="{'grey' if name == 'Hello' else 'orange'}") {name} World 71 | Label(background="{'grey' if name != 'Hello' else 'orange'}") {name} World 72 | ``` 73 | 74 | ``` json tab="Data" 75 | { 76 | "name": "Hello" 77 | } 78 | ``` 79 | 80 | ![Inline](img/layout/inline.png) 81 | 82 | ## Nested data mapping 83 | 84 | You can iterate the data via the **for** attribute. For each child, a new property **user** will be available, that references the list row. 85 | 86 | ``` pug tab="Layout" 87 | Box(orient="vertical" for="{user in users if user.age <= 30}") 88 | Box(orient="horizontal") 89 | Label(background="{'grey' if user.age < 25 else 'orange'}") User: {user.name} 90 | ``` 91 | 92 | ``` json tab="Data" 93 | { 94 | "users": [ 95 | { "name": "Horst", "age": 30 }, 96 | { "name": "Sandra", "age": 20 }, 97 | { "name": "Andreas", "age": 40 } 98 | ] 99 | } 100 | ``` 101 | 102 | ![Nested Widgets](img/layout/nested.png) 103 | -------------------------------------------------------------------------------- /layoutx/widgets/scroll_frame.py: -------------------------------------------------------------------------------- 1 | import tkinter.ttk as ttk 2 | import tkinter as tk 3 | from .widget import Widget 4 | from layoutx.utils import is_windows 5 | 6 | 7 | class AutoScrollbar(ttk.Scrollbar): 8 | def set(self, lo, hi): 9 | if float(lo) <= 0.0 and float(hi) >= 1.0: 10 | if self.grid_info(): 11 | self.grid_remove() 12 | else: 13 | if not self.grid_info(): 14 | self.grid() 15 | super().set(lo, hi) 16 | 17 | 18 | class ScrollFrame(Widget): 19 | def __init__(self, master, **kwargs): 20 | super().__init__(tk=ttk.Frame(master=master), **kwargs) 21 | self._canvas = tk.Canvas(self._tk, borderwidth=0, background="#ffffff") #place canvas on self 22 | 23 | self._viewPort = ttk.Frame(self._canvas) #place a frame on the canvas, this frame will hold the child widgets 24 | 25 | self._tk.grid_columnconfigure(0, weight=1) 26 | self._tk.grid_columnconfigure(1, weight=0) 27 | self._tk.grid_rowconfigure(0, weight=1) 28 | 29 | self._vsb = AutoScrollbar(self._tk, orient="vertical", command=self._canvas.yview) #place a scrollbar on self 30 | self._vsb.grid(row=0, column=1, sticky='ns') 31 | self._canvas.configure(yscrollcommand=self._vsb.set) 32 | 33 | self._canvas.bind('', self._bound_to_mousewheel) 34 | self._canvas.bind('', self._unbound_to_mousewheel) 35 | self._canvas.grid(row=0, column=0, sticky="nswe") 36 | self._window = self._canvas.create_window(0, 0, window=self._viewPort, anchor="nw") #add view port frame to canvas 37 | 38 | self._viewPort.bind("", self._on_frame_configure) #bind an event whenever the size of the viewPort frame changes. 39 | self._canvas.bind("", self._on_canvas_configure) 40 | 41 | @property 42 | def container(self): 43 | return self._viewPort 44 | 45 | def _bound_to_mousewheel(self, event): 46 | if is_windows(): 47 | self._canvas.bind_all("", self._on_mousewheel) 48 | else: 49 | self._canvas.bind_all("", self._on_mousewheel) 50 | self._canvas.bind_all("", self._on_mousewheel) 51 | 52 | def _unbound_to_mousewheel(self, event): 53 | if is_windows(): 54 | self._canvas.unbind_all("") 55 | else: 56 | self._canvas.unbind_all("") 57 | self._canvas.unbind_all("") 58 | 59 | def _on_mousewheel(self, event): 60 | delta = 1 if (event.num == 5 or event.delta == -120) else -1 61 | if self._canvas.winfo_height() < self._viewPort.winfo_height(): 62 | self._canvas.yview_scroll(delta, "units") 63 | 64 | def _on_canvas_configure(self, event): 65 | self._canvas.itemconfig(self._window, width=event.width) 66 | #self._canvas.itemconfig(self._window, height=event.height) 67 | 68 | def _on_frame_configure(self, event): 69 | self._canvas.configure(scrollregion=self._canvas.bbox("all")) #whenever the size of the frame changes, alter the scroll region respectively. 70 | 71 | def on_disposed(self): 72 | self._vsb.destroy() 73 | self._canvas.destroy() 74 | self._mainFrame.destroy() -------------------------------------------------------------------------------- /layoutx/_parser.py: -------------------------------------------------------------------------------- 1 | import pypugjs 2 | from pypugjs.parser import Parser 3 | from pypugjs.nodes import Tag 4 | from typing import List 5 | from operator import itemgetter 6 | import ast 7 | 8 | 9 | class XMLElement: 10 | def __init__(self, tag: str, attrib: dict): 11 | self._tag = tag 12 | self._attrib = attrib 13 | self._children: List = [] 14 | self._text = None 15 | 16 | @property 17 | def count_children(self): 18 | return len(self._children) 19 | 20 | @property 21 | def children(self): 22 | return self._children 23 | 24 | @property 25 | def tag(self): 26 | return self._tag 27 | 28 | @property 29 | def text(self): 30 | return self._text 31 | 32 | @text.setter 33 | def text(self, value): 34 | self._text = value 35 | 36 | def add_child(self, child): 37 | if not self.has_child(child): 38 | self._children.append(child) 39 | 40 | def remove_child(self, child): 41 | if self.has_child(child): 42 | self._children.remove(child) 43 | 44 | def has_child(self, child): 45 | return child in self._children 46 | 47 | @property 48 | def attributes(self): 49 | return self._attrib 50 | 51 | def get_attribute(self, key, default= None): 52 | return self._attrib.get(key, default) 53 | 54 | def set_attribute(self, key, value): 55 | self._attrib[key] = value 56 | 57 | def remove_attribute(self, key): 58 | if self.has_attrib(key): 59 | del self._attrib[key] 60 | 61 | def has_attribute(self, key): 62 | return key in self._attrib 63 | 64 | 65 | class Compiler(object): 66 | def __init__(self, node): 67 | self._node = node 68 | self._buffer = None 69 | 70 | def compile(self): 71 | self.visit(self._node, root=self._buffer) 72 | return self._buffer 73 | 74 | def visit(self, node, **kwargs): 75 | self.visitNode(node, **kwargs) 76 | 77 | def visitNode(self, node, **kwargs): 78 | name = node.__class__.__name__ 79 | visit_fn = getattr(self, f"visit{name}", None) 80 | if visit_fn: 81 | visit_fn(node, **kwargs) 82 | else: 83 | raise NotImplementedError(f"Node {name} not supported") 84 | 85 | def visitBlock(self, block, **kwargs): 86 | for node in block.nodes: 87 | self.visit(node, **kwargs) 88 | 89 | def visitTag(self, tag, **kwargs): 90 | attrs = {} 91 | for attr in tag._attrs: 92 | name, value = itemgetter("name", "val")(attr) 93 | attrs[name] = ast.literal_eval(value) 94 | element = XMLElement(tag.name, attrs) 95 | 96 | if kwargs.get("root"): 97 | kwargs.get("root").add_child(element) 98 | else: 99 | self._buffer = element 100 | 101 | self.visit(tag.block, root=element) 102 | if tag.text: 103 | self.visit(tag.text, root=element) 104 | 105 | def visitText(self, text, **kwargs): 106 | if kwargs.get("root").text: 107 | kwargs.get("root").text += '\n' + ''.join(text.nodes).strip() 108 | else: 109 | kwargs.get("root").text = ''.join(text.nodes).strip() 110 | 111 | def visitString(self, text, **kwargs): 112 | self.visitText(text, **kwargs) 113 | 114 | 115 | def parse_pug_to_obj(template: str): 116 | try: 117 | block = Parser(template).parse() 118 | return Compiler(block).compile() 119 | except Exception as e: 120 | raise ValueError(str(e)) -------------------------------------------------------------------------------- /layoutx/app.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | import tkinter.font as tkFont 3 | from .store import Store 4 | from .view import View, ResizeOption 5 | from .utils import Singleton, is_windows 6 | from ._registry import RegistryNode 7 | from .tkDnD import TkinterDnD 8 | import logging 9 | import asyncio 10 | 11 | __all__ = ["Application"] 12 | 13 | @Singleton 14 | class Application(RegistryNode): 15 | def __init__(self): 16 | super().__init__(widget = self, name = "app") 17 | 18 | #Import Widgets 19 | import layoutx.widgets 20 | self._widgets = {} 21 | for name in layoutx.widgets.__all__: 22 | self._widgets.update({name : getattr(layoutx.widgets, name)}) 23 | 24 | self._tk = None 25 | self._loop = None 26 | self._root_node = None 27 | self._style = None 28 | self._config = {} 29 | 30 | def setup(self, store: Store, rootView: View, font=None, style: str=None, interval=1/120, loop=None): 31 | if not self._tk: 32 | self._tk = TkinterDnD.Tk() 33 | self._loop = loop if loop else asyncio.get_event_loop() 34 | self._tk.protocol("WM_DELETE_WINDOW", self.close) 35 | self._ui_task = self._loop.create_task(self._updater(interval)) 36 | 37 | # Pick first system font as default if none given 38 | if font: 39 | self._config["font"] = font 40 | else: 41 | if is_windows(): 42 | self._config["font"] = {"family": "Courier New", "size": 12} if "Courier New" in tkFont.families() else {"family":tkFont.families()[1], "size": 12} 43 | else: 44 | self._config["font"] = {"family": "DejaVu Sans Mono", "size": 12} if "DejaVu Sans Mono" in tkFont.families() else {"family":tkFont.families()[1], "size": 12} 45 | 46 | if style and not self._style: 47 | try: 48 | from ttkthemes import ThemedStyle 49 | self._style = ThemedStyle(self._tk) 50 | self._style.set_theme(style) 51 | except ImportError: 52 | # ttkstyles not installed 53 | self._style = tk.ttk.Style() 54 | else: 55 | self._style = tk.ttk.Style() 56 | 57 | if self._root_node: 58 | self.remove_node(self._root_node) 59 | 60 | self._root_node = self.add_view( 61 | rootView( 62 | tkinter=self._tk, 63 | store=store 64 | ) 65 | ) 66 | self._root_node.widget.redraw() 67 | 68 | @property 69 | def loop(self): 70 | return self._loop 71 | 72 | def close(self): 73 | self._ui_task.add_done_callback(lambda *_: self._cleanup()) 74 | self._ui_task.cancel() 75 | 76 | @property 77 | def config(self): 78 | return self._config 79 | 80 | @property 81 | def style(self): 82 | return self._style 83 | 84 | def run( self ): 85 | self._loop.run_forever() 86 | self._loop.close() 87 | 88 | def get_root_node(self) -> RegistryNode: 89 | return self._root_node 90 | 91 | def get_view(self, name: str) -> RegistryNode: 92 | filter_view = self.filter_children(name=name) 93 | if len(filter_view) == 1: 94 | return filter_view[0] 95 | else: 96 | raise ValueError(f"View {name} not registed") 97 | 98 | def add_view(self, view: View) -> RegistryNode: 99 | name = view.__class__.__name__ 100 | old_view = self.filter_children(name=name) 101 | if len(old_view) > 0: 102 | self.remove_node(old_view[0]) 103 | if len(self.children) > 0: 104 | view.hide() 105 | return self._add_node(widget=view, name=view.__class__.__name__) 106 | 107 | def add_custom_widget(self, name, cls): 108 | if name in self._widgets: 109 | raise ValueError(f"Widget name: {name} already exists") 110 | 111 | self._widgets[name] = cls 112 | 113 | def update(self): 114 | self._tk.update() 115 | 116 | def get_widget_cls(self, name): 117 | if name not in self._widgets: 118 | raise KeyError(f"Widget: {name}, does not exist or was never added to the registry") 119 | return self._widgets[name] 120 | 121 | async def _updater(self, interval): 122 | while True: 123 | self.update() 124 | await asyncio.sleep(interval) 125 | 126 | def _cleanup(self): 127 | self._loop.stop() 128 | self._tk.destroy() 129 | -------------------------------------------------------------------------------- /src-docs/index.md: -------------------------------------------------------------------------------- 1 | ![Banner](img/Banner_no_bg.png) 2 | 3 | ## Why 4 | Python has multiple solutions to develop GUI applications e.g. PyQT5, tkinter, wx, etc. 5 | But often you end up with a lot of spaghetti to create the GUI and connect it to your data. 6 | This code is tedious to program, hard to maintain and its missing a quick preview function. 7 | 8 | So instead wouldn't it be nice to just write: 9 | 10 | ``` pug tab="Layout" 11 | ScrollFrame 12 | Label Username 13 | Input(value="{{username}}") 14 | Label Password 15 | Input(value="{{password}}") 16 | Box(orient="horizontal") 17 | Button(command="{login}") Login 18 | Button(command="{clear}") Clear 19 | ``` 20 | 21 | ``` json tab="Data" 22 | { 23 | 'username': 'User', 24 | 'password': 'Password', 25 | } 26 | ``` 27 | 28 | That creates the following application: 29 | ![Demo App](img/index/example-gui.png) 30 | 31 | Take a look at the curly brackets in the `value` property. Here you directly bind the widgets to your data. 32 | 33 | ## Features 34 | - Drag and Drop support (tkDnD) 35 | - Supports async by default (powered by asyncio) 36 | - Two-way data binding 37 | - Flexible layout by design 38 | - Application scrolls automatically 39 | - Simple layout syntax powered by Pug (former Jade) 40 | - Widget parameter support inline python scripting 41 | - ttk themes included 42 | - Lightweight and fast 43 | - Add any custom tkinter widget 44 | 45 | ## Installation 46 | Use pip to get the latest version. 47 | 48 | ```pip install layoutx``` (minimal version) 49 | 50 | ```pip install layoutx[more_widgets, styles]``` (full version) 51 | 52 | ```python -m layoutx.install_tkdnd``` (install tkdnd) 53 | 54 | After installing the basic library, the setup will check if **tkDnD** is already installed. If not, it is fetched and installed automatically. (Currently does not work for MacOS X, please create a PR, as I have no Mac to test the coding) 55 | 56 | Additionally the command line tool: `lxdesigner` is installed. 57 | 58 | !!! danger 59 | The designer needs the addon **more_widgets** installed in order to function 60 | 61 | ## Addons 62 | Some dependencies of this project have a GPL v3 license. They are excluded into separate addons. 63 | Please note by installing these dependencies, you confirm to the GPL license agreement. 64 | 65 | This project itself is licensed under MIT. 66 | 67 | ### more widgets: 68 | More information see: [ttkwidgets Github](https://github.com/TkinterEP/ttkwidgets/) 69 | 70 | - Input 71 | - ComboBox 72 | - TextArea 73 | - Scale 74 | 75 | ```pip install layoutx[more_widgets]``` 76 | 77 | ### ttk themes: 78 | More information see: [ttkthemes Github](https://github.com/TkinterEP/ttkthemes) 79 | 80 | ```pip install layoutx[styles]``` 81 | 82 | ## Integrated Designer 83 | If you want to create a simple mockup for your application, use the integrated designer with terminal command: 84 | 85 | ```lxdesigner``` 86 | 87 | ![Designer](img/index/designer.png) 88 | 89 | The Designer is entirely written with **LayoutX** and a good example, how easily dynamic GUIs can be created. 90 | 91 | The designer will display any error and debug information in the terminal window. 92 | 93 | ## Your first application 94 | 95 | ``` python 96 | from layoutx import app # Import the app singleton 97 | from layoutx.store import create_store 98 | from layoutx.view import View, ResizeOption 99 | 100 | store = create_store({}, { "name": "World" }) 101 | 102 | class RootView(View): 103 | geometry = "250x50+100+100" 104 | title = "My first app" 105 | resizable = ResizeOption.NONE 106 | template = """\ 107 | ScrollFrame 108 | Box(orient="horizontal") 109 | Label Hello 110 | Input(value="{{name}}") 111 | Button(command="{say_my_name}") Say my name! 112 | """ 113 | def say_my_name(self): 114 | print(self.store.state["name"]) 115 | 116 | if __name__ == "__main__": 117 | app.setup(store=store, rootView=RootView) 118 | app.run() 119 | ``` 120 | 121 | ![Getting Started](img/index/getting-started.png) 122 | 123 | Pressing the button will print: `User` 124 | 125 | We now have build a python cross-platform GUI with a vue-like template declaration and a redux data store in only 22 lines of code. 126 | 127 | In the next chapter the frameworks architecture is explained into detail. 128 | 129 | ## Styling your application 130 | 131 | Layoutx uses [ttkthemes](https://ttkthemes.readthedocs.io/en/latest/), to style your application. 132 | To use it, include a theme in the app setup. 133 | 134 | ```python 135 | app.setup(store=store, rootView=RootView, style="elegance") 136 | ``` 137 | 138 | ![Getting Started Theme](img/index/getting-started-theme.png) -------------------------------------------------------------------------------- /layoutx/view.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import weakref 3 | import inspect 4 | import tkinter as tk 5 | from enum import Enum 6 | from .store import Store 7 | from ._parser import parse_pug_to_obj 8 | from traceback import format_exc 9 | from inspect import iscoroutine 10 | from os import path 11 | 12 | __all__ = [ "ResizeOption", "View" ] 13 | 14 | class ResizeOption(Enum): 15 | NONE = (0, 0) 16 | BOTH = (1, 1) 17 | X_ONLY = (1, 0) 18 | Y_ONLY = (0, 1) 19 | 20 | 21 | class View: 22 | template: str = '' 23 | title = "New Window" 24 | geometry = "800x600+100+100" 25 | resizable = ResizeOption.BOTH 26 | icon = None 27 | 28 | def __init__(self, store:Store, tkinter = None, logger=None): 29 | self._store = weakref.proxy(store) 30 | self._widget = None 31 | self._logger = weakref.proxy(logger) if logger else logging.getLogger(self.__class__.__name__) 32 | self._logger.setLevel(logging.DEBUG) 33 | self._tk = tkinter if tkinter else tk.Toplevel() 34 | if tkinter is None: 35 | self._tk.protocol("WM_DELETE_WINDOW", self.dispose) 36 | 37 | if self.icon == None: 38 | import inspect 39 | from pathlib import Path 40 | self.icon = Path(inspect.getfile(inspect.currentframe())).parent / 'resources' / 'favicon.gif' 41 | 42 | menu = self.set_menu() 43 | if menu: 44 | self._set_menu(menu) 45 | self.set_title(self.title) 46 | self.set_geometry(self.geometry) 47 | self.set_resizable(self.resizable) 48 | 49 | def _init(self): 50 | self._templateTree = parse_pug_to_obj(self.template) 51 | from layoutx import app 52 | view_name = self.__class__.__name__ 53 | self._node = app.get_view(view_name) 54 | self._widget = self._node.add_widget( 55 | node=self._templateTree 56 | ) 57 | if path.isfile(self.icon): 58 | icon = tk.PhotoImage(file=self.icon) 59 | self._node.app._tk.call('wm', 'iconphoto', self._tk, icon) 60 | self._widget.widget.tk.pack(side="top", fill="both", expand=True) 61 | 62 | def set_menu(self): 63 | return None 64 | 65 | def set_resizable( self, resizable: ResizeOption ): 66 | self._tk.resizable(*resizable.value) 67 | 68 | def set_geometry( self, geometry: str): 69 | self._tk.geometry(geometry) 70 | 71 | def set_title( self, title:str ): 72 | self._tk.title(title) 73 | 74 | def show( self ): 75 | self.redraw() 76 | self._tk.deiconify() 77 | 78 | def hide( self ): 79 | self._tk.withdraw() 80 | 81 | @property 82 | def tk(self): 83 | return self._tk 84 | 85 | @property 86 | def container(self): 87 | return self._tk 88 | 89 | @property 90 | def logger(self): 91 | return self._logger 92 | 93 | @property 94 | def store(self) -> Store: 95 | return self._store 96 | 97 | def redraw(self, template=None): 98 | try: 99 | if self._widget: 100 | from layoutx import app 101 | view_node = app.get_view(self.__class__.__name__) 102 | view_node.clear_children() 103 | finally: 104 | self._widget = None 105 | if template: 106 | self.template = template 107 | self._init() 108 | 109 | def execute_in_loop(self, method=None): 110 | if not callable(method): 111 | # Nothing to evaluate 112 | return method 113 | 114 | def wrapper(*args, **kwargs): 115 | loop = self._node.app.loop 116 | logger = self._logger 117 | 118 | try: 119 | co = method(*args, **kwargs) 120 | 121 | if co and iscoroutine(co): 122 | loop.create_task(co) 123 | except: 124 | if logger: 125 | logger.warn(f"Error executing method") 126 | logger.warn(format_exc()) 127 | return wrapper 128 | 129 | def dispose(self): 130 | if isinstance(self._tk, tk.Tk): 131 | raise ValueError("Cannot dispose main window, use app.close()") 132 | if self._widget: 133 | from layoutx import app 134 | view_node = app.get_view(self.__class__.__name__) 135 | view_node.clear_children() 136 | self._widget = None 137 | if self._tk: 138 | self._tk.destroy() 139 | self._tk = None 140 | 141 | def _set_menu(self, menu_tree: dict): 142 | def tree_traverse(menu_bar: tk.Menu, tree: dict): 143 | for key, value in tree.items(): 144 | if isinstance(value, dict): 145 | submenu = tk.Menu(menu_bar, tearoff=0) 146 | menu_bar.add_cascade(label=key, menu=tree_traverse(submenu, value)) 147 | else: 148 | menu_bar.add_command(label=key, command=self.execute_in_loop(value)) 149 | return menu_bar 150 | 151 | menubar = tree_traverse(menu_bar=tk.Menu(self._tk), tree=menu_tree) 152 | self._tk.config(menu=menubar) -------------------------------------------------------------------------------- /layoutx/widgets/input.py: -------------------------------------------------------------------------------- 1 | from .widget import Widget 2 | import tkinter as tk 3 | from tkinter import ttk, StringVar 4 | from ttkwidgets.autocomplete import AutocompleteEntry 5 | from ttkwidgets.autocomplete import AutocompleteCombobox 6 | from ..tkDnD import DND_FILES 7 | from pathlib import Path 8 | 9 | 10 | class BaseInput(Widget): 11 | def __init__(self, tk, **kwargs): 12 | super().__init__(tk=tk, **kwargs) 13 | 14 | self._setter = self.connect_to_prop("value", self.on_changed_value) 15 | self._trace = self._textv.trace_add("write", 16 | lambda *_: self._setter(self._textv.get()) 17 | ) 18 | self.connect_to_prop("suggestion", self.on_changed_suggestion) 19 | 20 | def on_changed_suggestion(self, value): 21 | if self._textv.get() == None or self._textv.get() == "": 22 | if value and len(value) > 0: 23 | self._setter(value[0]) 24 | self._tk.set_completion_list(value if value else []) 25 | 26 | def on_changed_value(self, value): 27 | self._textv.set(value) 28 | 29 | def on_disposed(self): 30 | self._textv.trace_remove("write", self._trace) 31 | self._setter = None 32 | 33 | class FileInput(BaseInput): 34 | def __init__(self, master, **kwargs): 35 | self._onlydir = False 36 | self._ext = ('Any File', '.*') 37 | self._rootdir = None 38 | self._excludefiles = [] 39 | self._suggestions = [] 40 | self._file_suggestions = None 41 | 42 | self._textv = StringVar() 43 | self._box = ttk.Frame(master=master) 44 | self._box.grid_columnconfigure(0, weight=1) 45 | self._box.grid_columnconfigure(1, weight=0) 46 | self._box.grid_rowconfigure(0, weight=1) 47 | 48 | self._input = AutocompleteCombobox( 49 | master=self._box, 50 | completevalues=[], 51 | textvariable=self._textv 52 | ) 53 | 54 | # Redirect configure to input 55 | setattr(self._box, "config", self._input.config) 56 | setattr(self._box, "configure", self._input.configure) 57 | setattr(self._box, "keys", self._input.keys) 58 | setattr(self._box, "cget", self._input.cget) 59 | setattr(self._box, "winfo_class", self._input.winfo_class) 60 | setattr(self._box, "bind", self._input.bind) 61 | setattr(self._box, "set_completion_list", self._input.set_completion_list) 62 | 63 | super().__init__(tk=self._box, **kwargs) 64 | self.connect_to_prop("onlydir", self._on_onlydir_changed) 65 | self.connect_to_prop("ext", self._on_ext_changed) 66 | self.connect_to_prop("rootdir", self._on_rootdir_changed) 67 | self.connect_to_prop("excludefiles", self._on_excludefiles_changed) 68 | #self.connect_to_prop("suggestion", self.on_changed_suggestion) 69 | 70 | self._input.drop_target_register(DND_FILES) 71 | self._input.dnd_bind('<>', self._drop) 72 | self._input.grid(row=0, column=0, sticky="news") 73 | 74 | self._btn = ttk.Button(master=self._box, command=self._load_file, text="Browse...") 75 | self._btn.grid(row=0, column=1) 76 | 77 | def _on_excludefiles_changed(self, value): 78 | self._excludefiles = value if value else [] 79 | self._set_suggestions() 80 | 81 | def _on_onlydir_changed(self, value): 82 | self._onlydir = value 83 | 84 | def on_changed_suggestion(self, value): 85 | if value: 86 | self._suggestions = value 87 | self._set_suggestions() 88 | 89 | def _set_suggestions(self): 90 | if self._rootdir and Path(self._rootdir).exists() and self._file_suggestions == None: 91 | get_suggestion_from_ext = lambda ext: list(filter(lambda fn: Path(fn).stem not in self._excludefiles, [str(fn) for fn in Path(self._rootdir).rglob(f"*{ext[1]}")])) 92 | 93 | if isinstance(self._ext, list): 94 | from functools import reduce 95 | self._file_suggestions = reduce(lambda a, curr: a + get_suggestion_from_ext(curr), []) 96 | else: 97 | self._file_suggestions = get_suggestion_from_ext(self._ext) 98 | if isinstance(self._file_suggestions, list): 99 | self._input.set_completion_list(self._suggestions + self._file_suggestions) 100 | else: 101 | self._input.set_completion_list(self._suggestions) 102 | 103 | def _on_ext_changed(self, value): 104 | self._ext = value if value else ('Any File', '.*') 105 | self._set_suggestions() 106 | 107 | def _on_rootdir_changed(self, value): 108 | self._rootdir = value 109 | self._set_suggestions() 110 | 111 | @property 112 | def container(self): 113 | return self._box 114 | 115 | def on_disposed(self): 116 | self._box.destroy() 117 | self._btn.destroy() 118 | super().on_disposed() 119 | 120 | def _load_file(self): 121 | if self._onlydir: 122 | f = tk.filedialog.askdirectory() 123 | else: 124 | f = tk.filedialog.askopenfilename(filetypes=self._ext if isinstance(self._ext, list) else [self._ext]) 125 | if f is None or f == '': 126 | return 127 | self._textv.set(str(Path(f))) 128 | 129 | def _drop(self, event): 130 | if event.data: 131 | files = self._tk.tk.splitlist(event.data) 132 | for f in files: 133 | self._textv.set(f) 134 | break 135 | return event.action 136 | 137 | class Input(BaseInput): 138 | def __init__(self, master, **kwargs): 139 | self._textv = StringVar() 140 | super().__init__( 141 | tk=AutocompleteEntry( 142 | master=master, 143 | completevalues=[], 144 | textvariable=self._textv 145 | ), **kwargs 146 | ) -------------------------------------------------------------------------------- /src-docs/widgets.md: -------------------------------------------------------------------------------- 1 | ## BaseWidget 2 | The BaseWidget has no visual representation, but all other widgets inherit it. It provides some lifecycle methods and attributes to ease widget development. 3 | 4 | ### Boilerplate 5 | 6 | This is a simple example that creates a tkinter label widget. 7 | The **TK** parent widget is always passed to the init method via the name **master**. 8 | 9 | ```python 10 | from layoutx.widgets import Widget 11 | from tkinter import ttk 12 | 13 | class MyWidget(Widget): 14 | def __init__(self, master, **kwargs): 15 | super().__init__(tk=ttk.Label(master=master), **kwargs) 16 | 17 | ``` 18 | 19 | ### Lifecycle Methods 20 | 21 | Lifecycle Methods can be hooked into, to change the behavior of the widget in runtime. For example, if additional data needs to be cleaned up, if the widget is disposed. You can hook into these methods, by extending the base class methods. 22 | 23 | ```python 24 | class MyWidget(Widget): 25 | # We overwrite Parent Lifecycle method 26 | def on_init(self): 27 | print("I was created") 28 | 29 | def on_dispose(self): 30 | print("I am about to be disposed") 31 | ``` 32 | 33 | | Name | Description | 34 | | --- | --- | 35 | | on_init | tkinter widget is created, databinding is already usable | 36 | | on_placed | widget was placed in parent | 37 | | on_disposed | called before widget is disposed (use it for cleanup tasks that are unique to this widget) | 38 | | on_children_cleared | child widgets are cleared | 39 | | on_children_updated | child widgets were placed | 40 | 41 | ### Internal Properties 42 | 43 | To ease development some properties are provided for each widget. 44 | Access them directly via `self.`. 45 | 46 | | Name | Description | DataType | 47 | | --- | --- | --- | 48 | | hidden | is widget hidden | Boolean | 49 | | path | a unique path in the registry tree, that represents this widget | String | 50 | | view | the parent view | layoutx.view.View | 51 | | children | own children tkinter widgets | [ Tk.Widget ] | 52 | | store | the data store | layoux.store.Store | 53 | | tk | own tkinter widget | Tk.Widget | 54 | | text | Text given from layout declaration | string \| None | 55 | | container | In case the widget consists of multiple tkinter widget, use this attribute to specify where to place children widgets | Tk.Widget | 56 | 57 | ### Helper Methods 58 | 59 | In order to ease widget interaction with the view and data store this is abstracted by helper methods. 60 | 61 | !!! important 62 | Please note that you do not need to manually watch any tkinter property with **connect_to_prop**. The Framework automatically listen to changes for these properties and forwards them to the tkinter widget. 63 | 64 | Automatically watched properties include: 65 | - tkinter widget keys 66 | - ["ipadx", "ipady", "padx", "pady", "sticky", 67 | "columnspan", "rowspan", "height", "width", 68 | "if", "for", "orient", "enabled", "foreground", "background", "font", "style", "fieldbackground"] 69 | 70 | Keep that in mind when developing custom widgets 71 | 72 | | Name | Description | Pseudocode | 73 | | --- | --- | --- | 74 | | get_attr | Get current value of widget attribute, if None use specified default value | `get_attr("background", "grey")` | 75 | | set_attr | Set value of widget attribute | set_attr("background", "red") | 76 | | set_prop_value | Same as set_attr but notifies Store | `set_prop_value("background", "red")` | 77 | | connect_to_prop | Subscribe to changes of a widget attribute, if changes to the attribute are possible will return a setter method, to pass value changes to | `connect_to_prop("background", self._on_changed_mybackground)` | 78 | 79 | ### Style Compatibility 80 | 81 | The framework adds an compatibility layer, so the basic styling options: 82 | 83 | - font 84 | - background 85 | - foreground 86 | - fieldbackground 87 | 88 | can still be used with ttk widgets. 89 | 90 | 91 | ## Widgets Example 92 | ### Label 93 | 94 | #### Simple 95 | 96 | ``` pug 97 | Label A new Label 98 | ``` 99 | 100 | #### Multiline 101 | 102 | ``` pug 103 | Label 104 | | first line 105 | | second line 106 | ``` 107 | 108 | ### Box 109 | A collection widget, it aligns childs vertical by default. 110 | 111 | 112 | #### Horizontal 113 | 114 | ``` pug 115 | Box(orient="horizontal") 116 | ``` 117 | 118 | #### Vertical 119 | 120 | ``` pug 121 | Box 122 | ``` 123 | 124 | ### Input 125 | 126 | !!! important 127 | When use pass suggestion using static binding, exclude the double quotes! 128 | 129 | ```pug 130 | Input(value='{{name}}' suggestion=['1', '2']) 131 | ``` 132 | 133 | ### ComboBox 134 | 135 | !!! important 136 | When use pass suggestion using static binding, exclude the double quotes! 137 | 138 | ```pug 139 | ComboBox(value='{{name}}', suggestion=['1','2']) 140 | ``` 141 | 142 | ### DropTarget 143 | 144 | ```pug 145 | DropTarget(on_drop="{on_drop}") 146 | ``` 147 | 148 | !!! important 149 | Do not forget to include a view method (on_drop) in this case 150 | 151 | ### Button 152 | 153 | ```pug 154 | Button(command="{cmd}") 155 | ``` 156 | 157 | ### Textarea 158 | 159 | ```pug 160 | TextArea(highlightstyle='monokai', spaces='2', language='python', value='{{code}}' ) 161 | ``` 162 | 163 | !!! note 164 | Highlighting powered by pygments 165 | 166 | ### SplitPane 167 | 168 | ```pug 169 | SplitPane 170 | Box 1 171 | Box 2 172 | ``` 173 | 174 | ### Scale 175 | 176 | ```pug 177 | Scale(value='{{counter}}' to="100") 178 | ``` 179 | 180 | ### CheckBox 181 | 182 | ```pug 183 | CheckBox(value='{{isBool}}') Check me 184 | ``` -------------------------------------------------------------------------------- /layoutx/tools/designer.py: -------------------------------------------------------------------------------- 1 | from layoutx import app 2 | from layoutx.store import create_store 3 | from layoutx.view import View, ResizeOption 4 | import asyncio 5 | import ast 6 | import logging 7 | import sys 8 | 9 | 10 | def create_view(template, methods): 11 | newline = '\n' 12 | exec(f""" 13 | class DemoView(View): 14 | geometry = "400x400+900+100" 15 | title = "Demo" 16 | template = \"\"\"{template}\"\"\" 17 | 18 | { newline.join([f" {line}" for line in methods.split(newline)]) } 19 | """) 20 | 21 | return eval("DemoView") 22 | 23 | store = create_store({}, { 24 | "data": """\ 25 | { 26 | "name": "news", 27 | "counter": 0, 28 | "isBool": True, 29 | "code": "import antigravity" 30 | }""", 31 | "template": """\ 32 | ScrollFrame 33 | Button(command="{partial(print_hello, name)}") asyncio 34 | Button(command="{reducer}") 35 | | Hello {name} 36 | Label(:Button-1:="{partial(print_hello, 'label clicked')}") {name} 37 | Label hello {getter()} 38 | """, 39 | "view": """\ 40 | async def print_hello(self, txt, *args): 41 | import asyncio 42 | await asyncio.sleep(1) 43 | print("tkEvent", args) 44 | print(txt) 45 | 46 | def getter(self): 47 | return 'dynamic getter' 48 | 49 | def on_drop(self, path): 50 | print(path) 51 | 52 | def reducer(self): 53 | self.store.dispatch("SET_NAME", "from reducer") 54 | """, 55 | "store": """\ 56 | { 57 | "SET_NAME": lambda state, payload: {**state, **{"name": payload}} 58 | } 59 | """ 60 | }) 61 | 62 | class RootView(View): 63 | geometry = "800x600+100+100" 64 | title = "Designer" 65 | template = """\ 66 | SplitPane(orient="vertical") 67 | SplitPane 68 | Box Template 69 | TextArea(autocomplete="{get_autocomplete()}", highlightstyle="monokai", spaces="2", language="pug" value="{{template}}") 70 | Box Store data 71 | TextArea(highlightstyle="monokai", spaces="2", language="json" value="{{data}}" ) 72 | SplitPane 73 | Box View Methods 74 | TextArea(highlightstyle="monokai", spaces="2", language="python" value="{{view}}") 75 | Box Store Reducer 76 | TextArea(highlightstyle="monokai", spaces="2", language="python" value="{{store}}") 77 | """ 78 | demoView = None 79 | demoStore = None 80 | 81 | def get_autocomplete(self): 82 | return [{ 83 | "name": "Label", 84 | "value": "Label hello" 85 | },{ 86 | "name": "Button", 87 | "value": "Button(command=\"{cmd}\")" 88 | }, 89 | { 90 | "name": "Box", 91 | "value": "Box(orient=\"vertical\")" 92 | },{ 93 | "name": "SplitPane", 94 | "value": "SplitPane" 95 | },{ 96 | "name": "ComboBox", 97 | "value": "ComboBox(value='{{name}}', suggestion=['1','2'])" 98 | },{ 99 | "name": "CheckBox", 100 | "value": "CheckBox(value='{{isBool}}') Check me" 101 | },{ 102 | "name": "DropTarget", 103 | "value": "DropTarget(on_drop=\"{on_drop}\")" 104 | },{ 105 | "name": "TextArea", 106 | "value": "TextArea(highlightstyle='monokai', spaces='2', language='python', value='{{code}}' )" 107 | },{ 108 | "name": "ScrollFrame", 109 | "value": "ScrollFrame" 110 | },{ 111 | "name": "Scale", 112 | "value": "Scale(value='{{counter}}' to=\"100\")" 113 | },{ 114 | "name": "Input", 115 | "value": "Input(value='{{name}}' suggestion=['1', '2'])" 116 | }] 117 | 118 | def set_menu(self): 119 | return { 120 | "Reload UI": self.update_ui, 121 | "Update Data": self.update_data, 122 | "Import Example": self.import_data, 123 | "Export Example": self.export_data 124 | } 125 | 126 | @property 127 | def _get_state(self): 128 | return self._store.state 129 | 130 | def export_data(self): 131 | from tkinter import filedialog 132 | import json 133 | filename = filedialog.asksaveasfilename(filetypes=[('LxDesign File', '.lxconfig')], defaultextension=".lxconfig") 134 | if filename: 135 | with open(filename, "w", encoding='utf-8') as dataFile: 136 | dataFile.writelines(json.dumps(self._get_state)) 137 | 138 | 139 | def import_data(self): 140 | from tkinter import filedialog, messagebox 141 | try: 142 | data = filedialog.askopenfilename(filetypes=[('LxDesign File', '.lxconfig')], defaultextension=".lxconfig") 143 | if data is None: 144 | return 145 | with open(data, "r", encoding='utf-8') as dataFile: 146 | self.store._state.on_next( ast.literal_eval(dataFile.read() )) 147 | except: 148 | messagebox.showerror("Error", "Import Data not valid") 149 | 150 | def _create_view(self): 151 | self.demoStore = create_store(eval(self._get_state["store"]), ast.literal_eval(self._get_state["data"])) 152 | view_class = create_view(self._get_state["template"], self._get_state["view"]) 153 | self.demoView = app.add_view(view_class(store=self.demoStore)).widget 154 | 155 | if len(self.demoView.logger.handlers) == 0: 156 | handler = logging.StreamHandler(sys.stdout) 157 | handler.setLevel(logging.DEBUG) 158 | formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') 159 | handler.setFormatter(formatter) 160 | self.demoView.logger.addHandler(handler) 161 | self.demoView.show() 162 | 163 | def update_ui(self): 164 | if self.demoView: 165 | self.demoView.redraw(self._get_state["template"]) 166 | else: 167 | self._create_view() 168 | 169 | def update_data(self): 170 | if self.demoStore: 171 | self.demoStore._state.on_next(ast.literal_eval(self._get_state["data"])) 172 | else: 173 | self._create_view() 174 | 175 | def update_store(self): 176 | self._create_view() 177 | 178 | def main(): 179 | app.setup(store=store, rootView=RootView) 180 | app.run() 181 | 182 | if __name__ == "__main__": 183 | app.setup(store=store, rootView=RootView) 184 | app.run() -------------------------------------------------------------------------------- /layoutx/utils.py: -------------------------------------------------------------------------------- 1 | import platform 2 | from typing import List, Callable 3 | from copy import deepcopy, copy 4 | import ast 5 | from functools import reduce 6 | 7 | 8 | __all__ = [ "get_os", "is_windows", "safe_get", "safe_set", "safer_eval", "safe_list", "safe_dict" ] 9 | 10 | class Singleton: 11 | def __init__(self, decorated): 12 | self._decorated = decorated 13 | 14 | def instance(self, **kwargs): 15 | try: 16 | return self._instance 17 | except AttributeError: 18 | self._instance = self._decorated(**kwargs) 19 | return self._instance 20 | 21 | def __call__(self): 22 | raise TypeError('Singletons must be accessed through `instance()`.') 23 | 24 | def get_os(): 25 | return platform.system() 26 | 27 | def is_windows(): 28 | return get_os() == "Windows" 29 | 30 | def safe_get(data, keys: List[str]): 31 | for key in keys: 32 | try: 33 | data = data[int(key)] if isinstance(data, list) else data[key] 34 | except KeyError: 35 | return None 36 | return data 37 | 38 | def safe_set(data, keys: List[str], value): 39 | data_copy = deepcopy(data) 40 | dic = data_copy 41 | for key in keys[:-1]: 42 | dic = dic[int(key)] if isinstance(dic, list) else dic.setdefault(key, {}) 43 | dic[int(keys[-1]) if isinstance(dic, list) else keys[-1]] = value 44 | return data_copy 45 | 46 | safe_list = [ 47 | 'abs', 'all', 'any', 'ascii', 'bin', 'callable', 48 | 'chr', 'dir', 'divmod', 'format','getattr', 49 | 'hasattr', 'hash', 'hex', 'id', 'input', 'isinstance', 50 | 'issubclass', 'iter', 'len', 'max', 'min', 'next', 'oct', 51 | 'ord', 'pow', 'repr', 'round', 'sorted', 'sum', 'bool', 52 | 'bytearray', 'bytes', 'complex', 'dict', 'enumerate', 53 | 'filter', 'float', 'frozenset', 'int', 'list', 'map', 'object', 54 | 'range', 'reversed', 'set', 'slice', 'str', 'tuple', 'type', 'zip', 'partial' 55 | ] 56 | 57 | safe_dict = dict([ (k, globals()["__builtins__"].get(k)) for k in safe_list ]) 58 | from functools import partial 59 | safe_dict["partial"] = partial 60 | 61 | def safer_eval(exp: str, variables={}): 62 | return eval(exp, {"__builtins__":None}, dict(copy(safe_dict), **variables)) 63 | 64 | def security_check_ast(tree: ast.AST, allowed_internal_names = []): 65 | for node in ast.walk(tree): 66 | # Block Assignments, Imports, Deletion, etc. 67 | if isinstance(node, ( 68 | ast.Assign, ast.Assert, ast.AnnAssign, ast.AugAssign, 69 | ast.alias, ast.FunctionDef, ast.Import, ast.ImportFrom, ast.Del, ast.Delete, ast.Global, ast.Nonlocal)): 70 | raise ValueError(f"Illegal Expression Type: { node.__class__.mro()[0].__name__}") 71 | 72 | import sys 73 | # Python 3.8 74 | if sys.version_info >= (3, 8) and isinstance(node, ast.NamedExpr): 75 | raise ValueError(f"Illegal Expression Type: { node.__class__.mro()[0].__name__}") 76 | 77 | if isinstance(node, ast.Name): 78 | if node.id not in allowed_internal_names and node.id.startswith("_"): 79 | raise ValueError(f"Internal Name cannot be used!") 80 | 81 | if isinstance(node, (ast.NameConstant, ast.Constant)): 82 | if str(node.value).startswith("_"): 83 | raise ValueError(f"Internal Name cannot be used!") 84 | 85 | if isinstance(node, ast.Attribute): 86 | if node.attr.startswith("_"): 87 | raise ValueError(f"Internal Name cannot be used!") 88 | 89 | if isinstance(node, ast.Call) and isinstance(node.func, ast.Name): 90 | if node.func.id.startswith("_"): 91 | raise ValueError(f"Function { node.func.id } cannot be used!") 92 | 93 | #if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute): 94 | # raise ValueError(f"Cannot use object methods") 95 | 96 | 97 | def compile_exp(exp: str, path_mapping={}, allowed_names=[], attr2sub=False, mode="eval"): 98 | return compile_ast(ast.parse(exp).body[0].value, path_mapping=path_mapping, allowed_names=allowed_names, attr2sub=attr2sub, mode=mode) 99 | 100 | def compile_ast(tree: ast.AST, path_mapping={}, allowed_names=[], attr2sub=False, mode="eval"): 101 | #allowed_names += ["_r", "_s"] 102 | # Security Check 103 | security_check_ast(tree, allowed_internal_names=allowed_names) 104 | 105 | reslv_abs_mod = ResolveAbsolutePath(name_mapping=path_mapping, no_name_replace=attr2sub, reserved_names=allowed_names) 106 | attr2sub_mod = Attribute2Subscribe() 107 | tree = reslv_abs_mod.visit(tree) 108 | tree = attr2sub_mod.visit(tree) 109 | 110 | if mode == "eval": 111 | expr = ast.Expression(body=tree) 112 | elif mode == "exec": 113 | tree.ctx = ast.Store() 114 | expr = ast.Module(body=[ 115 | ast.Assign( 116 | targets=[tree], 117 | value =ast.Name( 118 | id='_value', 119 | ctx=ast.Load()) 120 | ) 121 | ], type_ignores=[]) 122 | expr = ast.fix_missing_locations(expr) 123 | return compile(expr, filename="", mode=mode) 124 | 125 | def eval_compiled(comp, variables={}): 126 | try: 127 | return eval(comp, {"__builtins__":None}, dict(copy(safe_dict), **variables)) 128 | except: 129 | return None 130 | 131 | def set_state(comp, variables, value): 132 | exec(comp, {"__builtins__":None, "_value": value}, variables) 133 | 134 | class ResolveAbsolutePath(ast.NodeTransformer): 135 | def __init__(self, name_mapping=None, reserved_names=[], no_name_replace=False): 136 | super().__init__() 137 | self._no_name_replace = no_name_replace 138 | self._name_mapping = name_mapping 139 | self._ignore_built_in = safe_list + reserved_names 140 | 141 | def visit_Name(self, node: ast.Name): 142 | if node.id in self._ignore_built_in or self._no_name_replace: 143 | return self.generic_visit(node) 144 | 145 | if self._name_mapping and node.id in self._name_mapping: 146 | return self.generic_visit(self._name_mapping[node.id]) 147 | 148 | return self.generic_visit(node) 149 | 150 | class Attribute2Subscribe(ast.NodeTransformer): 151 | def visit_Attribute(self, node: ast.Attribute): 152 | attr = ast.Str(s=node.attr) 153 | index = ast.copy_location(ast.Index( 154 | value=ast.copy_location(attr, node) 155 | ), node) 156 | 157 | return self.generic_visit(ast.copy_location( 158 | ast.Subscript( 159 | value = node.value, 160 | ctx= ast.Load(), 161 | slice=index 162 | ),node)) 163 | 164 | -------------------------------------------------------------------------------- /src-docs/showcase.md: -------------------------------------------------------------------------------- 1 | # Showcase 2 | 3 | Here are some apps that were designed quick and dirty. 4 | 5 | !!! imporant 6 | For many examples you need additional libraries like: **aiohttp** 7 | 8 | ## Cat Viewer 9 | This example showcases, how you can create a custom widget, access a remote resource and dynamically load an image in tkinter. 10 | 11 | ![ImageViewer](img/showcase/imageviewer.png) 12 | 13 | ??? Source Code 14 | ```python 15 | from layoutx import app 16 | from layoutx.store import create_store 17 | from layoutx.view import View, ResizeOption 18 | 19 | store = create_store({ 20 | "SET_IMAGE": lambda state, payload: {**state, **{"data": payload}} 21 | }, { "data": "" }) 22 | 23 | class ImageShowcase(View): 24 | geometry = "800x600+200+200" 25 | title = "ImageViewer" 26 | resizable = ResizeOption.NONE 27 | template= """\ 28 | Box 29 | Label(weight="0") Image Viewer 30 | ImageViewer(name="image" background="black" imagedata="{data}") 31 | Button(weight="0" height="20" command="{load_image}") New Image 32 | """ 33 | 34 | async def load_image(self): 35 | # Find view child widget api not yet finalized 36 | imageViewer = self._widget.find_first("image") 37 | 38 | # Get tkinter attributes 39 | height = imageViewer.widget.tk.winfo_height() 40 | width = imageViewer.widget.tk.winfo_width() 41 | 42 | import aiohttp 43 | import io 44 | from random import randint 45 | 46 | imagedata = None 47 | session = aiohttp.ClientSession() 48 | async with session.get(f"http://placekitten.com/{width}/{height}?image={randint(0,17)}") as imageResource: 49 | from PIL import Image, ImageTk 50 | load = Image.open(io.BytesIO(await imageResource.read())) 51 | imagedata = ImageTk.PhotoImage(load) 52 | await session.close() 53 | self.store.dispatch("SET_IMAGE", imagedata) 54 | 55 | from layoutx.widgets import Widget 56 | from tkinter import ttk 57 | class ImageViewer(Widget): 58 | def __init__(self, master, **kwargs): 59 | super().__init__(tk=ttk.Label(master=master), **kwargs) 60 | self.connect_to_prop("imagedata", self.on_imagedata_changed) 61 | 62 | def on_imagedata_changed(self, imagedata): 63 | if imagedata == '': 64 | return 65 | self._tk.configure(image=imagedata) 66 | 67 | app.add_custom_widget("ImageViewer", ImageViewer) 68 | 69 | if __name__ == "__main__": 70 | app.setup(store=store, rootView=ImageShowcase) 71 | app.run() 72 | ``` 73 | 74 | ## Cat Facts 75 | This examples shows, how you could filter dynamically change the view depending on application state. 76 | 77 | ![Cat Facts](img/showcase/facts.png) 78 | 79 | ??? Source Code 80 | ```python 81 | from layoutx import app 82 | from layoutx.store import create_store 83 | from layoutx.view import View, ResizeOption 84 | 85 | store = create_store({}, { 86 | "facts": [], 87 | "loading": False 88 | }) 89 | 90 | class LoadFacts(View): 91 | geometry = "800x600+200+200" 92 | title = "FactLoader" 93 | resizable = ResizeOption.BOTH 94 | template = """\ 95 | ScrollFrame 96 | Label(if="{loading}") Loading, please wait ... 97 | Button(command="{load_facts}") load facts 98 | Box(for="{fact in facts if fact.type == 'cat'}") 99 | Box(orient="horizontal") 100 | Label(background="{'grey' if fact.deleted else 'green'}") {fact.text} 101 | """ 102 | 103 | async def load_facts(self): 104 | self.store.dispatch("SET_VALUE", { 105 | "path": ["loading"], 106 | "value": True 107 | }) 108 | import aiohttp 109 | session = aiohttp.ClientSession() 110 | facts_list = [] 111 | async with session.get("https://cat-fact.herokuapp.com/facts/random?animal_type=horse&amount=5") as facts: 112 | facts_list += await facts.json() 113 | 114 | async with session.get("https://cat-fact.herokuapp.com/facts/random?animal_type=cat&amount=5") as facts: 115 | facts_list += await facts.json() 116 | 117 | await session.close() 118 | 119 | self.store.dispatch("SET_VALUE", { 120 | "path": ["loading"], 121 | "value": False 122 | }) 123 | 124 | self.store.dispatch("SET_VALUE", { 125 | "path": ["facts"], 126 | "value": facts_list 127 | }) 128 | 129 | if __name__ == "__main__": 130 | app.setup(store=store, rootView=LoadFacts) 131 | app.run() 132 | ``` 133 | 134 | ## ToDo List 135 | A simple todo list a must for every framework. 136 | This also showcases, how you can create a subview for text editing. 137 | 138 | ![ToDoList](img/showcase/todo.png) 139 | 140 | ??? Source Code 141 | ```python 142 | from layoutx import app 143 | from layoutx.store import create_store 144 | from layoutx.view import View, ResizeOption 145 | from uuid import uuid4 146 | 147 | store = create_store({ 148 | "DELETE_TODO": lambda state, payload: {**state, **{"todos": list(filter(lambda x: x["id"] != payload, state["todos"])) }}, 149 | "ADD_TODO": lambda state, *_: {**state, **{"todos": state["todos"] + [{ "id": uuid4(), "text": "Edit me" }] }} 150 | }, { 151 | "todos": [], 152 | "selected": -1 153 | }) 154 | 155 | 156 | class ChangeToDoText(View): 157 | geometry = "300x50+500+500" 158 | title = "Change Text" 159 | resizable = ResizeOption.NONE 160 | template = """\ 161 | Box Edit Todo: {todos[selected].id} 162 | Input(value="{{todos[selected].text}}") 163 | """ 164 | 165 | class ToDoList(View): 166 | geometry = "500x100+200+200" 167 | title = "ToDoList" 168 | resizable = ResizeOption.BOTH 169 | template = """\ 170 | Box 171 | ScrollFrame To-Do\'s 172 | Box(orient="vertical" for="{todo in todos}") 173 | Box(orient="horizontal") 174 | Label {todo.text} 175 | Button(weight="0" command="{partial(change_todo, todo.id)}") Edit 176 | Button(weight="0" command="{partial(DELETE_TODO, todo.id)}") Del 177 | Button(weight="0" command="{ADD_TODO}") Add Todo 178 | """ 179 | 180 | # private attributes 181 | _editView = None 182 | 183 | def change_todo(self, todo_id): 184 | list_id = next((i for i, x in enumerate(self.store.state["todos"]) if x["id"] == todo_id), -1) 185 | if list_id == -1: 186 | return 187 | 188 | self.store.dispatch("SET_VALUE", { 189 | "path": ["selected"], 190 | "value": list_id 191 | }) 192 | if not self._editView or self._editView._tk.winfo_exists() == 0: 193 | self._editView = app.add_view( ChangeToDoText(store=store) ).widget 194 | self._editView.show() 195 | 196 | if __name__ == "__main__": 197 | app.setup(store=store, rootView=ToDoList) 198 | app.run() 199 | ``` -------------------------------------------------------------------------------- /layoutx/widgets/textarea.py: -------------------------------------------------------------------------------- 1 | from .widget import Widget 2 | import tkinter as tk 3 | from tkinter import Frame, Text, Scrollbar, Pack, Grid, Place, INSERT, END, Toplevel, Listbox 4 | from tkinter.constants import RIGHT, LEFT, Y, BOTH 5 | from tkinter.font import Font, BOLD, nametofont 6 | from .scroll_frame import AutoScrollbar 7 | from pygments.styles import get_style_by_name 8 | from pygments.lexers import get_lexer_by_name 9 | from ttkwidgets.autocomplete import AutocompleteEntryListbox 10 | 11 | 12 | class ScrolledText(Text): 13 | def __init__(self, master=None, **kw): 14 | self.frame = Frame(master) 15 | self.vbar = AutoScrollbar(self.frame, orient="vertical") 16 | self.vbar.grid(row=0, column=1, sticky="ns") 17 | self.frame.grid_columnconfigure(0, weight=1) 18 | self.frame.grid_columnconfigure(1, weight=0) 19 | self.frame.grid_rowconfigure(0, weight=1) 20 | 21 | kw.update({'yscrollcommand': self.vbar.set}) 22 | Text.__init__(self, self.frame, **kw) 23 | self.vbar['command'] = self.yview 24 | Text.grid(self, row=0, column=0, sticky="news") 25 | 26 | # Copy geometry methods of self.frame without overriding Text 27 | # methods -- hack! 28 | text_meths = vars(Text).keys() 29 | methods = vars(Pack).keys() | vars(Grid).keys() | vars(Place).keys() 30 | methods = methods.difference(text_meths) 31 | 32 | for m in methods: 33 | if m[0] != '_' and m != 'config' and m != 'configure' and m not in ["grid", "pack"]: 34 | setattr(self, m, getattr(self.frame, m)) 35 | 36 | def __str__(self): 37 | return str(self.frame) 38 | 39 | def pack(self, *args, **kwargs): 40 | self.frame.pack(*args, **kwargs) 41 | #self.frame.pack_propagate(False) 42 | 43 | def grid(self, *args, **kwargs): 44 | self.frame.grid(*args, **kwargs) 45 | 46 | class TextArea(Widget): 47 | def __init__(self, master, **kwargs): 48 | super().__init__(tk=ScrolledText(master=master, wrap=tk.WORD), **kwargs) 49 | self._spaces = ' ' 50 | self._lexer = None 51 | self._lexer_style = None 52 | self._autocomplete_list = None 53 | self._tk.bind('', self._set_data) 54 | self._tk.bind('', self._tab_to_spaces) 55 | self._tk.bind('', self._autoindent) 56 | self._tk.bind("", self._increase_size) 57 | self._tk.bind("", self._decrease_size) 58 | self._tk.bind("", self._autocomplete) 59 | self._value_setter = self.connect_to_prop("value", self.on_changed_value) 60 | self.connect_to_prop("spaces", self._on_changed_spaces) 61 | self.connect_to_prop("language", self._on_changed_language) 62 | self.connect_to_prop("highlightstyle", self._on_changed_highlightstyle) 63 | self.connect_to_prop("autocomplete", self._on_changed_autocomplete) 64 | 65 | def _on_changed_autocomplete(self, value): 66 | self._autocomplete_list = value 67 | 68 | def _autocomplete(self, event): 69 | if not self._autocomplete_list or len(self._autocomplete_list) == 0: 70 | return 71 | 72 | index = self._tk.index(INSERT).split(".") 73 | self._text_index = '.'.join(index) 74 | tw = Toplevel(self._tk) 75 | tw.wm_overrideredirect(True) 76 | 77 | font = self._get_font() 78 | font_size = int(font.cget("size")) 79 | 80 | tw.geometry(f"+{ self._tk.winfo_rootx() + int(index[1]) * int(font_size / 2) }+{ self._tk.winfo_rooty() + int(index[0]) * font_size }") 81 | 82 | self._listbox = AutocompleteEntryListbox(tw, font=font, allow_other_values=False, completevalues=[v["name"] for v in self._autocomplete_list]) 83 | self._listbox.pack() 84 | 85 | tw.lift() 86 | tw.focus_force() 87 | tw.grab_set() 88 | tw.grab_release() 89 | 90 | self._listbox.focus_force() 91 | 92 | self._listbox.listbox.bind("", self._autocomplete_selected) 93 | self._listbox.entry.bind("", self._autocomplete_selected) 94 | self._listbox.bind("", self._autocomplete_destroy) 95 | self._listbox.bind("", self._autocomplete_destroy) 96 | 97 | self._autocomplete_window = tw 98 | 99 | def _autocomplete_selected(self, event): 100 | value = next(v["value"] for v in self._autocomplete_list if v["name"] == self._listbox.get()) 101 | 102 | self._tk.insert(self._text_index, value) 103 | 104 | self._listbox.event_generate("") 105 | 106 | def _autocomplete_destroy(self, event): 107 | if self._autocomplete_window: 108 | self._autocomplete_window.destroy() 109 | self._autocomplete_window = None 110 | self._tk.focus_force() 111 | self._tk.mark_set("insert", self._text_index) 112 | 113 | def _get_font(self): 114 | return nametofont(self.get_style_attr('font')) 115 | 116 | def _increase_size(self, event): 117 | font = self._get_font() 118 | font.configure(size=int(font.cget("size") + 1)) 119 | #self._tk.configure(font=font) 120 | 121 | def _decrease_size(self, event): 122 | font = self._get_font() 123 | font.configure(size=int(font.cget("size") - 1)) 124 | #self._tk.configure(font=font) 125 | 126 | def _highlight(self): 127 | if not self._lexer: 128 | return 129 | code = self._get_text() 130 | 131 | self._tk.mark_set("range_start", "1" + ".0") 132 | 133 | for token, value in self._lexer.get_tokens(code): 134 | if len(value) == 0: 135 | continue 136 | self._tk.mark_set("range_end", "range_start + %dc" % len(value)) 137 | self._tk.tag_add(str(token), "range_start", "range_end") 138 | self._tk.mark_set("range_start", "range_end") 139 | 140 | def _on_changed_highlightstyle(self, value): 141 | self._lexer_style = get_style_by_name(value) 142 | self._tk.configure( 143 | background=self._lexer_style.background_color, 144 | insertbackground=self._lexer_style.highlight_color, 145 | foreground=self._lexer_style.highlight_color) 146 | 147 | for tag in self._tk.tag_names(): 148 | self._tk.tag_delete(tag) 149 | 150 | for token, value in self._lexer_style.styles.items(): 151 | token_value = value.split(' ') 152 | foreground = list(filter(lambda x: x.startswith("#"), token_value)) 153 | 154 | if len(foreground) == 0: 155 | continue 156 | 157 | if str(token) == "Token.Text": 158 | self._tk.configure( 159 | insertbackground=foreground[0], 160 | foreground=foreground[0]) 161 | 162 | self._tk.tag_configure(str(token), foreground=foreground[0]) 163 | 164 | 165 | self._highlight() 166 | 167 | def _on_changed_language(self, value): 168 | if value: 169 | self._lexer = get_lexer_by_name(value) 170 | 171 | def _on_changed_spaces(self, value): 172 | self._spaces = ''.join([" "] * int(value)) 173 | 174 | def _autoindent(self, event): 175 | indentation = "" 176 | lineindex = self._tk.index("insert").split(".")[0] 177 | linetext = self._tk.get(lineindex+".0", lineindex+".end") 178 | 179 | for character in linetext: 180 | if character in [" ","\t"]: 181 | indentation += character 182 | else: 183 | break 184 | 185 | self._tk.insert(self._tk.index("insert"), "\n"+indentation) 186 | return "break" 187 | 188 | def _tab_to_spaces(self, event): 189 | self._tk.insert(self._tk.index("insert"), self._spaces) 190 | return "break" 191 | 192 | def _get_text(self): 193 | return self._tk.get("1.0", tk.END)[:-1] 194 | 195 | def _set_data(self, event): 196 | if self._value_setter: 197 | self._value_setter(self._get_text()) 198 | 199 | def on_changed_value(self, value): 200 | if value: 201 | index = self._tk.index(tk.INSERT) 202 | self._tk.delete("1.0", tk.END) 203 | self._tk.insert(tk.END, value) 204 | self._tk.mark_set("insert", index) 205 | self._tk.see(index) 206 | 207 | self._highlight() 208 | 209 | def on_disposed(self): 210 | self._tk.unbind('') -------------------------------------------------------------------------------- /layoutx/widgets/widget.py: -------------------------------------------------------------------------------- 1 | from layoutx._registry import RegistryNode 2 | from layoutx.view import View 3 | from layoutx.utils import get_os 4 | from ast import literal_eval 5 | import re 6 | import logging 7 | import weakref 8 | import tkinter 9 | from tkinter import font as tkFont 10 | from functools import partial 11 | from tkinter.constants import HORIZONTAL, VERTICAL 12 | 13 | 14 | possible_cursors = ["X_cursor","arrow","based_arrow_down","based_arrow_up","boat","bogosity","bottom_left_corner","bottom_right_corner","bottom_side","bottom_tee","box_spiral","center_ptr","circle","clock","coffee_mug","cross","cross_reverse","crosshair","diamond_cross","dot","dotbox","double_arrow","draft_large","draft_small","draped_box","exchange","fleur","gobbler","gumby","hand1","hand2","heart","icon","iron_cross","left_ptr","left_side","left_tee","leftbutton","ll_angle","lr_angle","man","middlebutton","mouse","pencil","pirate","plus","question_arrow","right_ptr","right_side","right_tee","rightbutton","rtl_logo","sailboat","sb_down_arrow","sb_h_double_arrow","sb_left_arrow","sb_right_arrow","sb_up_arrow","sb_v_double_arrow","shuttle","sizing","spider","spraycan","star","target","tcross","top_left_arrow","top_left_corner","top_right_corner","top_side","top_tee","trek","ul_angle","umbrella","ur_angle","watch","xterm"] 15 | 16 | if get_os() == "Windows": 17 | possible_cursors += ["no","starting","size","size_ne_sw","size_ns","size_nw_se","size_we","uparrow","wait"] 18 | if get_os() == "Darwin": 19 | possible_cursors += ["copyarrow","aliasarrow","contextualmenuarrow","text","cross-hair","closedhand","openhand","pointinghand","resizeleft","resizeright","resizeleftright","resizeup","resizedown","resizeupdown","none","notallowed","poof","countinguphand","countingdownhand","countingupanddownhand","spinning"] 20 | 21 | 22 | class Widget: 23 | def __init__(self, node: RegistryNode, tk: tkinter.Widget ): 24 | self._node = node 25 | self._tk = tk 26 | self._hidden = False 27 | self._style_name = None 28 | self._font = None 29 | 30 | # Helper Properties 31 | 32 | @property 33 | def hidden(self): 34 | return self._hidden 35 | 36 | @property 37 | def path(self): 38 | return self._node.path 39 | 40 | @property 41 | def parent(self): 42 | return self._node.parent 43 | 44 | @property 45 | def view(self) -> View: 46 | return self._node.view 47 | 48 | @property 49 | def children(self): 50 | return [child.widget for child in self._node.children] 51 | 52 | @property 53 | def store(self): 54 | return self.view.store 55 | 56 | @property 57 | def tk(self): 58 | return getattr(self, "_tk") 59 | 60 | @property 61 | def text(self): 62 | return self._node.text 63 | 64 | @property 65 | def container(self): 66 | return self._tk 67 | 68 | def _init(self): 69 | properties = self.tk.keys() 70 | if "style" in properties: 71 | orient_style = "" 72 | if "orient" in properties: 73 | orient_style = "Horizontal." if self.tk.cget("orient") == "horizontal" else "Vertical." 74 | self._style_name = f"{self.get_attr('style', self.path)}.{ orient_style }{self.tk.winfo_class()}" 75 | self._configure_tk("style", self._style_name) 76 | 77 | # Font Handling 78 | sysfontName = None 79 | if self._style_name: 80 | # ttk 81 | sysfontName = self._get_app_style().lookup(self._style_name, 'font') 82 | else: 83 | # tk 84 | if "font" in self._tk.keys(): 85 | sysfontName = self._tk.cget('font') 86 | 87 | if sysfontName and sysfontName.startswith("Tk"): 88 | font = tkFont.nametofont(sysfontName).actual() 89 | font.update(self._get_app_config("font")) 90 | self._font = tkFont.Font(**font) 91 | self._apply_style_attribute("font", self._font) 92 | 93 | # Read Layout Attributes 94 | for prop in [prop for prop in self._node.prop_mapping.keys() if prop not in ["if", "style", "value"]]: 95 | if prop == "if": 96 | self.connect_to_prop("if", self._on_changed_visibility) 97 | elif prop == "enabled": 98 | self.connect_to_prop("enabled", self._on_changed_state) 99 | elif prop == "cursor": 100 | self.connect_to_prop("cursor", self._on_changed_cursor) 101 | elif prop == "font": 102 | self.connect_to_prop("font", self._on_changed_font) 103 | elif prop == "foreground": 104 | self.connect_to_prop("foreground", self._on_changed_foreground) 105 | elif prop == "fieldbackground": 106 | self.connect_to_prop("fieldbackground", self._on_changed_fieldbackground) 107 | elif prop == "background": 108 | self.connect_to_prop("background", self._on_changed_background) 109 | elif self.parent.widget.tk and prop in ["weight", "pad", "ipadx", "ipay", "padx", "pady", "columnspan", "rowspan", "sticky", "minsize"]: 110 | self.connect_to_prop(prop, self.parent.widget.place_children) 111 | elif prop in properties: 112 | self.connect_to_prop(prop, partial(self._configure_tk, prop)) 113 | elif prop[0] == ":" and prop[-1] == ":": 114 | self.connect_to_prop(prop, partial(self._on_bind_event, f"<{prop[1:-1]}>")) 115 | 116 | # Lifecycle Methods 117 | def on_init(self): 118 | pass 119 | 120 | def on_children_cleared(self): 121 | pass 122 | 123 | def on_children_updated(self): 124 | pass 125 | 126 | def on_placed(self): 127 | pass 128 | 129 | def on_disposed(self): 130 | pass 131 | 132 | def get_style_attr(self, key, default=None): 133 | if key in self.tk.keys(): 134 | return self._tk.cget(key) 135 | elif self._style_name: 136 | return self._get_app_style().lookup(self._style_name, key) 137 | 138 | return None 139 | 140 | 141 | def get_attr(self, key, default=None): 142 | if key in self._node.prop_mapping: 143 | return self._node.prop_mapping[key]["value"] 144 | else: 145 | return self._node.get_attr(key, default) 146 | 147 | def set_attr(self, key, value): 148 | if key in self._node.prop_mapping: 149 | self._node.prop_mapping[key]["value"] = value 150 | 151 | def connect_to_prop(self, key, fn_changed=None): 152 | if key in self._node.prop_mapping: 153 | if fn_changed: 154 | self._node.add_prop_subscriber(key, fn_changed) 155 | fn_changed(self.view.execute_in_loop(self.get_attr(key))) 156 | return self._node.prop_mapping[key].get("setter") 157 | 158 | def set_prop_value(self, key, value): 159 | if key in self._node.prop_mapping: 160 | if "setter" in self._node.prop_mapping[key] and not self.hidden: 161 | self._node.prop_mapping[key]["setter"](value) 162 | 163 | def forget_children(self): 164 | for child in self.children: 165 | if child and child.tk: 166 | child.tk.grid_forget() 167 | 168 | def place_children(self, changed_value = None): 169 | self.forget_children() 170 | 171 | index = 0 172 | orientation = self.get_attr("orient", VERTICAL) 173 | 174 | if orientation == VERTICAL: 175 | self.container.grid_columnconfigure(0, weight=1) 176 | else: 177 | self.container.grid_rowconfigure(0, weight=1) 178 | 179 | for child in self.children: 180 | if child: 181 | 182 | grid_info = { 183 | "padx": int(child.get_attr("padx", "0")), 184 | "pady": int(child.get_attr("pady", "0")), 185 | "ipadx": int(child.get_attr("ipadx", "0")), 186 | "ipady": int(child.get_attr("ipady", "0")), 187 | "columnspan": int(child.get_attr("columnspan", "1")), 188 | "rowspan": int(child.get_attr("rowspan", "1")), 189 | "sticky": child.get_attr("sticky", "news") 190 | } 191 | 192 | if orientation == VERTICAL: 193 | grid_info["column"] = 0 194 | grid_info["row"] = index 195 | self.container.grid_rowconfigure( 196 | index, 197 | pad=int(child.get_attr("pad", "0")), 198 | minsize=int(child.get_attr("minsize", "0")), 199 | weight=int(child.get_attr("weight", "1"))) 200 | else: 201 | grid_info["row"] = 0 202 | grid_info["column"] = index 203 | self.container.grid_columnconfigure( 204 | index, 205 | pad=int(child.get_attr("pad", "0")), 206 | minsize=int(child.get_attr("minsize", "0")), 207 | weight=int(child.get_attr("weight", "1"))) 208 | child.tk.grid(**grid_info) 209 | index += 1 210 | child._node.placed() 211 | 212 | def hide_child(self): 213 | self.place_children() 214 | 215 | def show_child(self): 216 | self.place_children() 217 | 218 | def dispose(self): 219 | self.view.logger.debug(f"DISPOSE widget {self.path}") 220 | self._tk.destroy() 221 | self._tk = None 222 | 223 | # internal methods 224 | def _get_app_style(self): 225 | return self._node.app.style 226 | 227 | def _get_app_config(self, key): 228 | return self._node.app.config.get(key, None) 229 | 230 | def _configure_tk(self, name, value): 231 | if name in self.tk.keys(): 232 | self.tk.configure(**{name: value}) 233 | 234 | def _on_bind_event(self, name, value): 235 | self._tk.bind(name, self.view.execute_in_loop(value)) 236 | 237 | def _on_changed_cursor(self, value): 238 | if not value in possible_cursors: 239 | self.view.logger.error(f"cursor: {value} not supported!") 240 | return 241 | self._configure_tk("cursor", value) 242 | 243 | def _on_changed_font(self, value): 244 | if "family" in value and not value["family"] in list(tkFont.families()): 245 | self.view.logger.error(f"font-family: { value['family'] } not installed!") 246 | return 247 | self._font.configure(**value) 248 | 249 | def _apply_style_attribute(self, key, value): 250 | if key in self.tk.keys(): 251 | self._configure_tk(key, **value) if isinstance(value, dict) else self._configure_tk(key, value) 252 | elif self._style_name: 253 | self._get_app_style().configure(self._style_name, **{ key: value }) 254 | 255 | def _on_changed_background(self, value): 256 | self._apply_style_attribute("background", value) 257 | 258 | def _on_changed_fieldbackground(self, value): 259 | self._apply_style_attribute("fieldbackground", value) 260 | 261 | def _on_changed_foreground(self, value): 262 | self._apply_style_attribute("foreground", value) 263 | 264 | def _on_changed_visibility(self, value): 265 | self._hidden = not bool(value) 266 | if not self.parent: 267 | return 268 | 269 | if value: 270 | self._node.activate() 271 | self.parent.widget.show_child() 272 | else: 273 | self._node.deactivate() 274 | self.parent.widget.hide_child() 275 | 276 | def _on_changed_state(self, value): 277 | if value: 278 | self._configure_tk("state", "normal") 279 | else: 280 | self._configure_tk("state", "disabled") -------------------------------------------------------------------------------- /layoutx/tkDnD/TkinterDnD.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | '''Python wrapper for the tkdnd tk extension. 4 | The tkdnd extension provides an interface to native, platform specific 5 | drag and drop mechanisms. Under Unix the drag & drop protocol in use is 6 | the XDND protocol version 5 (also used by the Qt toolkit, and the KDE and 7 | GNOME desktops). Under Windows, the OLE2 drag & drop interfaces are used. 8 | Under Macintosh, the Cocoa drag and drop interfaces are used. 9 | 10 | Once the TkinterDnD2 package is installed, it is safe to do: 11 | 12 | from TkinterDnD2 import * 13 | 14 | This will add the classes TkinterDnD.Tk and TkinterDnD.TixTk to the global 15 | namespace, plus the following constants: 16 | PRIVATE, NONE, ASK, COPY, MOVE, LINK, REFUSE_DROP, 17 | DND_TEXT, DND_FILES, DND_ALL, CF_UNICODETEXT, CF_TEXT, CF_HDROP, 18 | FileGroupDescriptor, FileGroupDescriptorW 19 | 20 | Drag and drop for the application can then be enabled by using one of the 21 | classes TkinterDnD.Tk() or (in case the tix extension shall be used) 22 | TkinterDnD.TixTk() as application main window instead of a regular 23 | tkinter.Tk() window. This will add the drag-and-drop specific methods to the 24 | Tk window and all its descendants.''' 25 | 26 | import tkinter 27 | 28 | TkdndVersion = None 29 | 30 | def _require(tkroot): 31 | '''Internal function.''' 32 | global TkdndVersion 33 | try: 34 | TkdndVersion = tkroot.tk.call('package', 'require', 'tkdnd') 35 | except tkinter.TclError: 36 | raise RuntimeError('Unable to load tkdnd library.') 37 | return TkdndVersion 38 | 39 | class DnDEvent: 40 | """Internal class. 41 | Container for the properties of a drag-and-drop event, similar to a 42 | normal tkinter.Event. 43 | An instance of the DnDEvent class has the following attributes: 44 | action (string) 45 | actions (tuple) 46 | button (int) 47 | code (string) 48 | codes (tuple) 49 | commonsourcetypes (tuple) 50 | commontargettypes (tuple) 51 | data (string) 52 | name (string) 53 | types (tuple) 54 | modifiers (tuple) 55 | supportedsourcetypes (tuple) 56 | sourcetypes (tuple) 57 | type (string) 58 | supportedtargettypes (tuple) 59 | widget (widget instance) 60 | x_root (int) 61 | y_root (int) 62 | Depending on the type of DnD event however, not all attributes may be set. 63 | """ 64 | pass 65 | 66 | class DnDWrapper: 67 | '''Internal class.''' 68 | # some of the percent substitutions need to be enclosed in braces 69 | # so we can use splitlist() to convert them into tuples 70 | _subst_format_dnd = ('%A', '%a', '%b', '%C', '%c', '{%CST}', 71 | '{%CTT}', '%D', '%e', '{%L}', '{%m}', '{%ST}', 72 | '%T', '{%t}', '{%TT}', '%W', '%X', '%Y') 73 | _subst_format_str_dnd = " ".join(_subst_format_dnd) 74 | tkinter.BaseWidget._subst_format_dnd = _subst_format_dnd 75 | tkinter.BaseWidget._subst_format_str_dnd = _subst_format_str_dnd 76 | 77 | def _substitute_dnd(self, *args): 78 | """Internal function.""" 79 | if len(args) != len(self._subst_format_dnd): 80 | return args 81 | def getint_event(s): 82 | try: 83 | return int(s) 84 | except ValueError: 85 | return s 86 | def splitlist_event(s): 87 | try: 88 | return self.tk.splitlist(s) 89 | except ValueError: 90 | return s 91 | # valid percent substitutions for DnD event types 92 | # (tested with tkdnd-2.8 on debian jessie): 93 | # <> : %W, %X, %Y %e, %t 94 | # <> : %A, %W, %e 95 | # <> : all except : %D (always empty) 96 | # <> : all except %D (always empty) 97 | # <> :all except %D (always empty) 98 | # <> : all 99 | A, a, b, C, c, CST, CTT, D, e, L, m, ST, T, t, TT, W, X, Y = args 100 | ev = DnDEvent() 101 | ev.action = A 102 | ev.actions = splitlist_event(a) 103 | ev.button = getint_event(b) 104 | ev.code = C 105 | ev.codes = splitlist_event(c) 106 | ev.commonsourcetypes = splitlist_event(CST) 107 | ev.commontargettypes = splitlist_event(CTT) 108 | ev.data = D 109 | ev.name = e 110 | ev.types = splitlist_event(L) 111 | ev.modifiers = splitlist_event(m) 112 | ev.supportedsourcetypes = splitlist_event(ST) 113 | ev.sourcetypes = splitlist_event(t) 114 | ev.type = T 115 | ev.supportedtargettypes = splitlist_event(TT) 116 | try: 117 | ev.widget = self.nametowidget(W) 118 | except KeyError: 119 | ev.widget = W 120 | ev.x_root = getint_event(X) 121 | ev.y_root = getint_event(Y) 122 | return (ev,) 123 | tkinter.BaseWidget._substitute_dnd = _substitute_dnd 124 | 125 | def _dnd_bind(self, what, sequence, func, add, needcleanup=True): 126 | """Internal function.""" 127 | if isinstance(func, str): 128 | self.tk.call(what + (sequence, func)) 129 | elif func: 130 | funcid = self._register(func, self._substitute_dnd, needcleanup) 131 | # FIXME: why doesn't the "return 'break'" mechanism work here?? 132 | #cmd = ('%sif {"[%s %s]" == "break"} break\n' % (add and '+' or '', 133 | # funcid, self._subst_format_str_dnd)) 134 | cmd = '%s%s %s' %(add and '+' or '', funcid, 135 | self._subst_format_str_dnd) 136 | self.tk.call(what + (sequence, cmd)) 137 | return funcid 138 | elif sequence: 139 | return self.tk.call(what + (sequence,)) 140 | else: 141 | return self.tk.splitlist(self.tk.call(what)) 142 | tkinter.BaseWidget._dnd_bind = _dnd_bind 143 | 144 | def dnd_bind(self, sequence=None, func=None, add=None): 145 | '''Bind to this widget at drag and drop event SEQUENCE a call 146 | to function FUNC. 147 | SEQUENCE may be one of the following: 148 | <>, <>, <>, <>, 149 | <>, <>, <> . 150 | The callbacks for the > events, with the exception of 151 | <>, should always return an action (i.e. one of COPY, 152 | MOVE, LINK, ASK or PRIVATE). 153 | The callback for the <> event must return a tuple 154 | containing three elements: the drop action(s) supported by the 155 | drag source, the format type(s) that the data can be dropped as and 156 | finally the data that shall be dropped. Each of these three elements 157 | may be a tuple of strings or a single string.''' 158 | return self._dnd_bind(('bind', self._w), sequence, func, add) 159 | tkinter.BaseWidget.dnd_bind = dnd_bind 160 | 161 | def drag_source_register(self, button=None, *dndtypes): 162 | '''This command will register SELF as a drag source. 163 | A drag source is a widget than can start a drag action. This command 164 | can be executed multiple times on a widget. 165 | When SELF is registered as a drag source, optional DNDTYPES can be 166 | provided. These DNDTYPES will be provided during a drag action, and 167 | it can contain platform independent or platform specific types. 168 | Platform independent are DND_Text for dropping text portions and 169 | DND_Files for dropping a list of files (which can contain one or 170 | multiple files) on SELF. However, these types are 171 | indicative/informative. SELF can initiate a drag action with even a 172 | different type list. Finally, button is the mouse button that will be 173 | used for starting the drag action. It can have any of the values 1 174 | (left mouse button), 2 (middle mouse button - wheel) and 3 175 | (right mouse button). If button is not specified, it defaults to 1.''' 176 | # hack to fix a design bug from the first version 177 | if button is None: 178 | button = 1 179 | else: 180 | try: 181 | button = int(button) 182 | except ValueError: 183 | # no button defined, button is actually 184 | # something like DND_TEXT 185 | dndtypes = (button,) + dndtypes 186 | button = 1 187 | self.tk.call( 188 | 'tkdnd::drag_source', 'register', self._w, dndtypes, button) 189 | tkinter.BaseWidget.drag_source_register = drag_source_register 190 | 191 | def drag_source_unregister(self): 192 | '''This command will stop SELF from being a drag source. Thus, window 193 | will stop receiving events related to drag operations. It is an error 194 | to use this command for a window that has not been registered as a 195 | drag source with drag_source_register().''' 196 | self.tk.call('tkdnd::drag_source', 'unregister', self._w) 197 | tkinter.BaseWidget.drag_source_unregister = drag_source_unregister 198 | 199 | def drop_target_register(self, *dndtypes): 200 | '''This command will register SELF as a drop target. A drop target is 201 | a widget than can accept a drop action. This command can be executed 202 | multiple times on a widget. When SELF is registered as a drop target, 203 | optional DNDTYPES can be provided. These types list can contain one or 204 | more types that SELF will accept during a drop action, and it can 205 | contain platform independent or platform specific types. Platform 206 | independent are DND_Text for dropping text portions and DND_Files for 207 | dropping a list of files (which can contain one or multiple files) on 208 | SELF.''' 209 | self.tk.call('tkdnd::drop_target', 'register', self._w, dndtypes) 210 | tkinter.BaseWidget.drop_target_register = drop_target_register 211 | 212 | def drop_target_unregister(self): 213 | '''This command will stop SELF from being a drop target. Thus, SELF 214 | will stop receiving events related to drop operations. It is an error 215 | to use this command for a window that has not been registered as a 216 | drop target with drop_target_register().''' 217 | self.tk.call('tkdnd::drop_target', 'unregister', self._w) 218 | tkinter.BaseWidget.drop_target_unregister = drop_target_unregister 219 | 220 | def platform_independent_types(self, *dndtypes): 221 | '''This command will accept a list of types that can contain platform 222 | independnent or platform specific types. A new list will be returned, 223 | where each platform specific type in DNDTYPES will be substituted by 224 | one or more platform independent types. Thus, the returned list may 225 | have more elements than DNDTYPES.''' 226 | return self.tk.split(self.tk.call( 227 | 'tkdnd::platform_independent_types', dndtypes)) 228 | tkinter.BaseWidget.platform_independent_types = platform_independent_types 229 | 230 | def platform_specific_types(self, *dndtypes): 231 | '''This command will accept a list of types that can contain platform 232 | independnent or platform specific types. A new list will be returned, 233 | where each platform independent type in DNDTYPES will be substituted 234 | by one or more platform specific types. Thus, the returned list may 235 | have more elements than DNDTYPES.''' 236 | return self.tk.split(self.tk.call( 237 | 'tkdnd::platform_specific_types', dndtypes)) 238 | tkinter.BaseWidget.platform_specific_types = platform_specific_types 239 | 240 | def get_dropfile_tempdir(self): 241 | '''This command will return the temporary directory used by TkDND for 242 | storing temporary files. When the package is loaded, this temporary 243 | directory will be initialised to a proper directory according to the 244 | operating system. This default initial value can be changed to be the 245 | value of the following environmental variables: 246 | TKDND_TEMP_DIR, TEMP, TMP.''' 247 | return self.tk.call('tkdnd::GetDropFileTempDirectory') 248 | tkinter.BaseWidget.get_dropfile_tempdir = get_dropfile_tempdir 249 | 250 | def set_dropfile_tempdir(self, tempdir): 251 | '''This command will change the temporary directory used by TkDND for 252 | storing temporary files to TEMPDIR.''' 253 | self.tk.call('tkdnd::SetDropFileTempDirectory', tempdir) 254 | tkinter.BaseWidget.set_dropfile_tempdir = set_dropfile_tempdir 255 | 256 | ####################################################################### 257 | #### The main window classes that enable Drag & Drop for #### 258 | #### themselves and all their descendant widgets: #### 259 | ####################################################################### 260 | 261 | class Tk(tkinter.Tk, DnDWrapper): 262 | '''Creates a new instance of a tkinter.Tk() window; all methods of the 263 | DnDWrapper class apply to this window and all its descendants.''' 264 | def __init__(self, *args, **kw): 265 | tkinter.Tk.__init__(self, *args, **kw) 266 | self.TkdndVersion = _require(self) 267 | -------------------------------------------------------------------------------- /layoutx/_registry.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import re 3 | import ast 4 | from enum import Enum 5 | from copy import deepcopy 6 | from typing import List 7 | from functools import reduce, partial 8 | from .utils import Singleton, safer_eval, safe_get, compile_exp, compile_ast, eval_compiled, set_state 9 | from .view import View 10 | from ._parser import XMLElement 11 | from .store import Store 12 | import weakref 13 | import asyncio 14 | 15 | 16 | class WIDGET_LIFECYCLE(Enum): 17 | CHILDREN_CLEARED = "on_children_cleared" 18 | CHILDREN_UPDATED = "on_children_updated" 19 | PLACED = "on_placed" 20 | DISPOSE = "on_dispose" 21 | INIT = "on_init" 22 | 23 | reserverd_attributes = [ 24 | "ipadx", "ipady", "padx", "pady", "sticky", 25 | "columnspan", "rowspan", "height", "width", 26 | "if", "for", "orient", "enabled", "foreground", "background", 27 | "font", "style", "fieldbackground", "command", "state" 28 | ] 29 | 30 | non_reactive_attributes = [ 31 | "for", "style", "name", "orient" 32 | ] 33 | 34 | class RegistryNode: 35 | def __init__( 36 | self, 37 | widget, 38 | node: XMLElement = None, 39 | name: str = None, 40 | view: View = None, 41 | path_mapping = None, 42 | parent = None): 43 | 44 | self._widget = widget 45 | self._view = view 46 | if issubclass(self._widget.__class__, View): 47 | self._view = weakref.proxy(self._widget) 48 | 49 | self._name = name 50 | self._node = node 51 | self._nodes = [] 52 | self._parent = weakref.proxy(parent) if parent else None 53 | self._path_mapping = path_mapping 54 | self._prop_mapping = {} 55 | 56 | def has_node(self, node): 57 | return node in self._nodes 58 | 59 | def add_widget(self, node:XMLElement, path_mapping = None): 60 | from layoutx import app 61 | widget_class = app.get_widget_cls(node.tag) 62 | widget_node = self._add_node( 63 | name=node.get_attribute("name", None), 64 | widget=None, 65 | path_mapping=path_mapping if path_mapping else self._path_mapping, 66 | node=node, 67 | view=self._view 68 | ) 69 | widget_node._init_binding() 70 | if "if" in widget_node._prop_mapping: 71 | widget_node.add_prop_subscriber("if", widget_node.toggle_visible) 72 | if not widget_node._prop_mapping["if"]["value"]: 73 | return widget_node 74 | widget_node._init_tk(widget_class=widget_class) 75 | if node.count_children > 0: 76 | widget_node._init_repeater(node.get_attribute("for")) 77 | widget_node.widget._init() 78 | return widget_node 79 | 80 | def toggle_visible(self, value): 81 | if value: 82 | self.activate() 83 | else: 84 | self.deactivate() 85 | 86 | def activate(self): 87 | from layoutx import app 88 | self._init_tk(widget_class=app.get_widget_cls(self._node.tag)) 89 | if self._node.count_children > 0: 90 | self._init_repeater(self._node.get_attribute("for")) 91 | self.widget._init() 92 | if not self.parent: 93 | return 94 | self.parent.widget.show_child() 95 | 96 | def deactivate(self): 97 | for child in self.children: 98 | child.dispose() 99 | if self._prop_mapping: 100 | for key in [key for key in self._prop_mapping.keys() if key != "if"]: 101 | if "observer" in self._prop_mapping[key]: 102 | self._prop_mapping[key]["observer"].dispose() 103 | del self._prop_mapping[key]["observer"] 104 | if self._widget: 105 | self._widget.dispose() 106 | self._widget = None 107 | if not self.parent: 108 | return 109 | self.parent.widget.hide_child() 110 | 111 | def remove_node(self, node): 112 | self._nodes.remove(node) 113 | if self.has_node(node): 114 | node.dispose() 115 | 116 | def get_attr(self, key, default=None): 117 | return self._node.get_attribute(key, default) 118 | 119 | def set_attr(self, key, value): 120 | self._node.set_attribute(key, value) 121 | 122 | @property 123 | def text(self): 124 | return self._node.text.rstrip() if self._node.text else None 125 | 126 | @property 127 | def prop_mapping(self): 128 | return self._prop_mapping 129 | 130 | @property 131 | def app(self): 132 | from layoutx import app 133 | return app 134 | 135 | @property 136 | def name(self): 137 | return self._name 138 | 139 | @property 140 | def widget_type(self): 141 | return self._widget.__class__.__name__ 142 | 143 | @property 144 | def widget(self): 145 | return self._widget 146 | 147 | @property 148 | def view(self) -> View: 149 | return self._view 150 | 151 | @property 152 | def children(self) -> List[RegistryNode]: 153 | return self._nodes 154 | 155 | @property 156 | def parent(self): 157 | return self._parent 158 | 159 | def filter_children(self, name: str = None, widget_type: str = None): 160 | if name: 161 | return [child for child in self.children if child.name == name] 162 | else: 163 | return [child for child in self.children if child.widget_type == widget_type] 164 | 165 | @property 166 | def path(self): 167 | path = [] 168 | node = self 169 | while node and node.widget_type != "Application": 170 | part = '' 171 | parent = node.parent 172 | if node.name: 173 | child_names = parent.filter_children(name=node.name) 174 | part = node.name 175 | if len(child_names) > 1: 176 | part += f"[{child_names.index(node)}]" 177 | else: 178 | child_types = parent.filter_children(widget_type=node.widget_type) 179 | part = f"!{node.widget_type}" 180 | if len(child_types) > 1: 181 | part += f"[{child_types.index(node)}]" 182 | 183 | path.insert(0, part) 184 | node = node.parent 185 | 186 | return '.'.join(path) 187 | 188 | def find_by_name(self, name = ""): 189 | return self._find(path=f"{name}", skip=True, all=True) 190 | 191 | def find_by_component(self, name): 192 | return self._find(path=f"{name}", skip=True, all=True) 193 | 194 | def find_all(self, path = str): 195 | return self._find(path=path.split("."), all=True) 196 | 197 | def find_first(self, path = str): 198 | found = self._find(path=path.split("."), all=True) 199 | return found[0] if len(found) > 0 else None 200 | 201 | def placed(self): 202 | self.app.update() 203 | self._call_child_lifecycle(WIDGET_LIFECYCLE.PLACED) 204 | 205 | def load_children(self, path_mapping=None): 206 | for child in self._node.children: 207 | child_widget = self.add_widget(node=child, path_mapping=path_mapping) 208 | child_widget._call_child_lifecycle(WIDGET_LIFECYCLE.INIT) 209 | self._call_child_lifecycle(WIDGET_LIFECYCLE.CHILDREN_UPDATED) 210 | 211 | def clear_children(self): 212 | for node in self.children: 213 | node.dispose() 214 | self._nodes = [] 215 | self._call_child_lifecycle(WIDGET_LIFECYCLE.CHILDREN_CLEARED) 216 | 217 | def dispose(self): 218 | for child in self.children: 219 | child.dispose() 220 | 221 | self._call_child_lifecycle(method=WIDGET_LIFECYCLE.DISPOSE) 222 | if self._prop_mapping: 223 | for key in self._prop_mapping.keys(): 224 | if "observer" in self._prop_mapping[key]: 225 | self._prop_mapping[key]["observer"].dispose() 226 | self._prop_mapping = None 227 | self._path_mapping = None 228 | if self._widget: 229 | self._widget.dispose() 230 | self._widget = None 231 | self._nodes = [] 232 | self._node = None 233 | 234 | def add_prop_subscriber(self, key, subscriber): 235 | self._prop_mapping[key]["subscriber"].append(subscriber) 236 | 237 | def _call_child_lifecycle(self, method: WIDGET_LIFECYCLE): 238 | if self._widget: 239 | cb = getattr(self._widget, method.value, None) 240 | if cb: 241 | cb() 242 | 243 | def _init_tk(self, widget_class): 244 | self._widget = widget_class(node=self, master=self.parent.widget.container) 245 | self._view.logger.debug(f"INIT Widget: {self.path}") 246 | 247 | def _init_binding(self): 248 | for key, value in self._node.attributes.items(): 249 | if key in non_reactive_attributes: 250 | continue 251 | 252 | if value[0] == "{" and value[-1] == "}": 253 | value = value[1:-1] 254 | isTwoWay = False 255 | if value[0] == "{" and value[-1] == "}": 256 | isTwoWay = True 257 | value = value[1:-1] 258 | tree = ast.parse(value).body[0].value 259 | comp = compile_ast(tree, path_mapping=self._path_mapping ) 260 | if isTwoWay: 261 | self._connect(key, comp, compile_ast(tree, path_mapping=self._path_mapping, mode="exec" )) 262 | else: 263 | self._connect_exp(key, comp) 264 | else: 265 | self._prop_mapping[key] = { 266 | "subscriber": [], 267 | "value": value 268 | } 269 | 270 | text_exp = self._node.text 271 | if text_exp: 272 | if '{' in text_exp and '}' in text_exp: 273 | comp = compile_exp(f"f\"\"\"{text_exp}\"\"\"", path_mapping=self._path_mapping) 274 | self._connect_exp("text", comp) 275 | else: 276 | self._prop_mapping["text"] = { 277 | "subscriber": [], 278 | "value": text_exp 279 | } 280 | 281 | def _init_repeater(self, data_for: str): 282 | if data_for is None: 283 | self.load_children() 284 | self._widget.place_children() 285 | return 286 | 287 | if data_for[0] == "{": 288 | data_for = data_for[1:-1] 289 | self._for_target, *self._for_in = data_for.split(' ', 1) 290 | self._for_in = ''.join(self._for_in) 291 | #list( (x for x in enumerate([1,2,3]) if x[1] != 2 ) ) 292 | 293 | self._gen_exp = f"list( ((index, {self._for_target}) for (index, {self._for_target}) {self._for_in}))" 294 | self._for_tree = ast.parse(self._gen_exp).body[0].value 295 | self._for_iter = deepcopy(self._for_tree.args[0].generators[0].iter) 296 | #self._for_ifs = self._for_tree.args[0].args[0].generators[0].ifs 297 | #add enumerate to iter 298 | #Call(func=Name(id='enumerate', ctx=Load()), args= 299 | self._for_tree.args[0].generators[0].iter = ast.Call( 300 | func = ast.Name(id='enumerate', ctx=ast.Load()), 301 | keywords=[], 302 | args=[self._for_iter] 303 | ) 304 | self._for_tree = ast.fix_missing_locations(self._for_tree) 305 | self._iter_compiled = compile_ast( 306 | self._for_tree, 307 | path_mapping=self._path_mapping 308 | ) 309 | #import astor 310 | #print(astor.codegen.to_source(self._for_tree)) 311 | 312 | observer = self._view.store.select_compiled(self._iter_compiled, built_in=self.get_built_in(), logger=self.view.logger) 313 | 314 | self._prop_mapping["iter"] = { 315 | "observer": observer.subscribe(self._on_changed_children) 316 | } 317 | 318 | 319 | def _add_node(self, widget:str, name:str='', path_mapping=None, node=None, view=None): 320 | node = RegistryNode( 321 | widget=widget, 322 | name=name, 323 | node=node, 324 | parent=self, 325 | path_mapping=path_mapping, 326 | view=self._view) 327 | self._nodes.append(node) 328 | return node 329 | 330 | def _connect_exp(self, name: str, comp): 331 | observer = self._view.store.select_compiled(comp, built_in=self.get_built_in(), logger=self.view.logger) 332 | self._prop_mapping[name] = { 333 | "subscriber": [], 334 | "value": None 335 | } 336 | self._prop_mapping[name]["observer"] = observer.subscribe(self._on_prop_changed(name)) 337 | 338 | self._view.logger.debug(f"{self.path} [{name}] connect_exp") 339 | 340 | def _connect(self, name: str, comp, comp_set): 341 | # Check if path mapping for nested path 342 | 343 | observer = self._view.store.select_compiled(comp, built_in=self.get_built_in(), logger=self.view.logger) 344 | self._prop_mapping[name] = { 345 | "subscriber": [], 346 | "value": None 347 | } 348 | self._prop_mapping[name]["observer"] = observer.subscribe(self._on_prop_changed(name)) 349 | self._prop_mapping[name]["setter"] = self._set_connect_state_value(comp_set) 350 | 351 | self._view.logger.debug(f"{self.path} [{name}] connected") 352 | 353 | def _on_changed_children(self, value): 354 | if (value == None): 355 | return 356 | 357 | if len(value) == len(self.children): 358 | return 359 | 360 | self.clear_children() 361 | for index, item in value: 362 | path_mapping = deepcopy(self._path_mapping) if self._path_mapping else {} 363 | path_mapping[self._for_target] = ast.Subscript( 364 | value=self._for_iter, 365 | slice=ast.Index( 366 | value=ast.Constant( 367 | value=index, 368 | kind=None 369 | ) 370 | ), 371 | ctx=ast.Load() 372 | ) 373 | 374 | self.load_children(path_mapping=path_mapping) 375 | self._widget.place_children() 376 | 377 | def get_built_in(self): 378 | built_in_methods = {k:v for k,v in [(meth, getattr(self._view, meth)) for meth in dir(self._view) if callable(getattr(self._view, meth)) and not meth.startswith('_')] } if self._view else {} 379 | built_in_methods.update(self._view.store.state) 380 | built_in_methods.update(self._view.store.get_reducers()) 381 | return built_in_methods 382 | 383 | def _set_connect_state_value(self, comp): 384 | def wrapper(value): 385 | variables = self.get_built_in() 386 | set_state(comp, variables, value) 387 | 388 | self._view.store._state.on_next( 389 | { k:variables[k] for k in self._view.store.state.keys() } 390 | ) 391 | return wrapper 392 | 393 | def _on_prop_changed(self, name): 394 | def wrapper(value): 395 | if name in self._prop_mapping: 396 | if self._prop_mapping[name]["value"] == value: 397 | return 398 | self._prop_mapping[name]["value"] = value 399 | 400 | for cb in self._prop_mapping[name]["subscriber"]: 401 | if (self._prop_mapping["if"]["value"] if "if" in self._prop_mapping else True) or name != 'value': 402 | cb(self.view.execute_in_loop(value)) 403 | self._view.logger.debug(f"{self.path} [{name}] changed {value}") 404 | 405 | return wrapper 406 | 407 | def _find(self, path = [], skip=False, all=False): 408 | def read_part(text: str): 409 | match = re.match(r"^!?(.*?)(\[(.*?)\])?$", text) 410 | return (match[1], match[3] if match.lastindex > 1 else 0) 411 | 412 | def search_children(childs, path, skip, all): 413 | found = [] 414 | for child in childs: 415 | found += child._find(path = path, skip=skip, all=all) 416 | if len(found) > 0 and not all: 417 | return found 418 | return found 419 | 420 | if len(path) == 0 or len(self.children) == 0: 421 | return [] 422 | 423 | part, *_path = path 424 | if part == "*": 425 | return search_children(childs=self.children, path=_path, skip=True, all=all) 426 | 427 | name, index = read_part(part) 428 | childs = self.filter_children(widget_type=name) if part.startswith("!") else self.filter_children(name=name) 429 | 430 | if index == "*": 431 | if len(_path) == 0: 432 | return childs if all else [childs[0]] 433 | else: 434 | return search_children(childs, path=_path, skip=skip, all=all) 435 | 436 | if int(index) >= len(childs): 437 | return search_children(self.children, path=path, skip=skip, all=all) if skip else [] 438 | else: 439 | if len(_path) == 0: 440 | return [childs[int(index)]] 441 | return search_children(childs, path=_path, skip=False, all=all) -------------------------------------------------------------------------------- /layoutx/widgets/imageviewer.py: -------------------------------------------------------------------------------- 1 | import os 2 | import math 3 | import hashlib 4 | import warnings 5 | import tkinter as tk 6 | from tkinter import ttk 7 | from PIL import Image, ImageTk 8 | from .scroll_frame import AutoScrollbar 9 | from .widget import Widget 10 | from pathlib import Path 11 | 12 | MAX_IMAGE_PIXELS = 1500000000 # maximum pixels in the image, use it carefully 13 | 14 | class ImageViewer(Widget): 15 | def __init__(self, master, **kwargs): 16 | self._canvasImage = CanvasImage() 17 | self._canvasImage.init(master) 18 | super().__init__(tk=self._canvasImage._CanvasImage__imframe, **kwargs) 19 | self.connect_to_prop("value", self._on_image_changed) 20 | 21 | def _on_image_changed(self, path): 22 | if not isinstance(path, str) or Path(path).exists() and path != '': 23 | if self._canvasImage.busy: 24 | self._canvasImage.queue = path 25 | else: 26 | #self._canvasImage._CanvasImage__imframe.update() 27 | #self._canvasImage.canvas.update() 28 | try: 29 | self._canvasImage.new_image(path) 30 | self._canvasImage._CanvasImage__center_img() 31 | self._canvasImage._CanvasImage__show_image() 32 | except: 33 | self._canvasImage.path = "" 34 | self._canvasImage.busy = False 35 | self._canvasImage.init = False 36 | 37 | 38 | 39 | class CanvasImage: 40 | busy = False 41 | queue = "" 42 | path = "" 43 | init_widget = False 44 | master = None 45 | 46 | def _on_resize(self, *_): 47 | #self.master.grid_columnconfigure(0, weight=1) 48 | #self.master.grid_rowconfigure(0, weight=1) 49 | self.__imframe.grid_columnconfigure(0, weight=1) 50 | self.__imframe.grid_rowconfigure(0, weight=1) 51 | self.__imframe.update() 52 | 53 | def new_image(self, path): 54 | self.queue = "" 55 | self.busy = True 56 | self.imscale = 1.0 # scale for the canvas image zoom, public for outer classes 57 | self.__delta = 1.3 # zoom magnitude 58 | self.__filter = Image.ANTIALIAS # could be: NEAREST, BILINEAR, BICUBIC and ANTIALIAS 59 | self.__previous_state = 0 # previous state of the keyboard 60 | self.path = path # path to the image, should be public for outer classes 61 | # Decide if this image huge or not 62 | self.__huge = False # huge or not 63 | self.__huge_size = 14000 # define size of the huge image 64 | self.__band_width = 1024 # width of the tile band 65 | Image.MAX_IMAGE_PIXELS = MAX_IMAGE_PIXELS # suppress DecompressionBombError for big image 66 | with warnings.catch_warnings(): # suppress DecompressionBombWarning for big image 67 | warnings.simplefilter('ignore') 68 | self.__image = Image.open(self.path) # open image, but down't load it into RAM 69 | self.imwidth, self.imheight = self.__image.size # public for outer classes 70 | if self.imwidth * self.imheight > self.__huge_size * self.__huge_size and \ 71 | self.__image.tile[0][0] == 'raw': # only raw images could be tiled 72 | self.__huge = True # image is huge 73 | self.__offset = self.__image.tile[0][2] # initial tile offset 74 | self.__tile = [self.__image.tile[0][0], # it have to be 'raw' 75 | [0, 0, self.imwidth, 0], # tile extent (a rectangle) 76 | self.__offset, 77 | self.__image.tile[0][3]] # list of arguments to the decoder 78 | self.__min_side = min(self.imwidth, self.imheight) # get the smaller image side 79 | # Create image pyramid 80 | self.__pyramid = [self.smaller()] if self.__huge else [Image.open(self.path)] 81 | # Set ratio coefficient for image pyramid 82 | self.__ratio = max(self.imwidth, self.imheight) / self.__huge_size if self.__huge else 1.0 83 | self.__curr_img = 0 # current image from the pyramid 84 | self.__scale = self.imscale * self.__ratio # image pyramide scale 85 | self.__reduction = 2 # reduction degree of image pyramid 86 | (w, h), m, j = self.__pyramid[-1].size, 512, 0 87 | n = math.ceil(math.log(min(w, h) / m, self.__reduction)) + 1 # image pyramid length 88 | while w > m and h > m: # top pyramid image is around 512 pixels in size 89 | j += 1 90 | w /= self.__reduction # divide on reduction degree 91 | h /= self.__reduction # divide on reduction degree 92 | self.__pyramid.append(self.__pyramid[-1].resize((int(w), int(h)), self.__filter)) 93 | # Put image into container rectangle and use it to set proper coordinates to the image 94 | self.wrapper = self.canvas.create_rectangle((0, 0, self.imwidth, self.imheight), width=0) 95 | # Create MD5 hash sum from the image. Public for outer classes 96 | self.md5 = hashlib.md5(self.__pyramid[0].tobytes()).hexdigest() 97 | #self.__center_img() 98 | #self.__show_image() # show image on the canvas 99 | self.canvas.focus_set() # set focus on the canvas 100 | self.busy = False 101 | self.init_widget = True 102 | if self.queue != "": 103 | self.new_image(self.queue) 104 | 105 | """ Display and zoom image """ 106 | def init(self, placeholder): 107 | """ Initialize the ImageFrame """ 108 | self.master = placeholder 109 | self.__imframe = ttk.Frame(placeholder) # placeholder of the ImageFrame object 110 | self.__imframe.bind('', self._on_resize) 111 | 112 | #setattr(self.__imframe, "grid_forget", self.grid_forget) 113 | # Vertical and horizontal scrollbars for canvas 114 | hbar = AutoScrollbar(self.__imframe, orient='horizontal') 115 | vbar = AutoScrollbar(self.__imframe, orient='vertical') 116 | hbar.grid(row=1, column=0, sticky='we') 117 | vbar.grid(row=0, column=1, sticky='ns') 118 | # Create canvas and bind it with scrollbars. Public for outer classes 119 | self.canvas = tk.Canvas(self.__imframe, highlightthickness=0, 120 | xscrollcommand=hbar.set, yscrollcommand=vbar.set) 121 | self.canvas.grid(row=0, column=0, sticky='nswe') 122 | self.canvas.update() # wait till canvas is created 123 | hbar.configure(command=self.__scroll_x) # bind scrollbars to the canvas 124 | vbar.configure(command=self.__scroll_y) 125 | # Bind events to the Canvas 126 | self.canvas.bind('', self.__show_image()) # canvas is resized 127 | self.canvas.bind('', self.__move_from) # remember canvas position 128 | self.canvas.bind('', self.__move_to) # move canvas to the new position 129 | self.canvas.bind('', self.__wheel) # zoom for Windows and MacOS, but not Linux 130 | self.canvas.bind('', self.__wheel) # zoom for Linux, wheel scroll down 131 | self.canvas.bind('', self.__wheel) # zoom for Linux, wheel scroll up 132 | 133 | def smaller(self): 134 | """ Resize image proportionally and return smaller image """ 135 | w1, h1 = float(self.imwidth), float(self.imheight) 136 | w2, h2 = float(self.__huge_size), float(self.__huge_size) 137 | aspect_ratio1 = w1 / h1 138 | aspect_ratio2 = w2 / h2 # it equals to 1.0 139 | if aspect_ratio1 == aspect_ratio2: 140 | image = Image.new('RGB', (int(w2), int(h2))) 141 | k = h2 / h1 # compression ratio 142 | w = int(w2) # band length 143 | elif aspect_ratio1 > aspect_ratio2: 144 | image = Image.new('RGB', (int(w2), int(w2 / aspect_ratio1))) 145 | k = h2 / w1 # compression ratio 146 | w = int(w2) # band length 147 | else: # aspect_ratio1 < aspect_ration2 148 | image = Image.new('RGB', (int(h2 * aspect_ratio1), int(h2))) 149 | k = h2 / h1 # compression ratio 150 | w = int(h2 * aspect_ratio1) # band length 151 | i, j, n = 0, 0, math.ceil(self.imheight / self.__band_width) 152 | while i < self.imheight: 153 | j += 1 154 | print('\rOpening image: {j} from {n}'.format(j=j, n=n), end='') 155 | band = min(self.__band_width, self.imheight - i) # width of the tile band 156 | self.__tile[1][3] = band # set band width 157 | self.__tile[2] = self.__offset + self.imwidth * i * 3 # tile offset (3 bytes per pixel) 158 | self.__image.close() 159 | self.__image = Image.open(self.path) # reopen / reset image 160 | self.__image.size = (self.imwidth, band) # set size of the tile band 161 | self.__image.tile = [self.__tile] # set tile 162 | cropped = self.__image.crop((0, 0, self.imwidth, band)) # crop tile band 163 | image.paste(cropped.resize((w, int(band * k)+1), self.__filter), (0, int(i * k))) 164 | i += band 165 | print('\r' + (40 * ' ') + '\r', end='') # hide printed string 166 | return image 167 | 168 | @staticmethod 169 | def check_image(path): 170 | """ Check if it is an image. Static method """ 171 | # noinspection PyBroadException 172 | try: # try to open and close image with PIL 173 | Image.MAX_IMAGE_PIXELS = MAX_IMAGE_PIXELS # suppress DecompressionBombError for big image 174 | with warnings.catch_warnings(): # suppress DecompressionBombWarning for big image 175 | warnings.simplefilter(u'ignore') 176 | img = Image.open(path) 177 | img.close() 178 | except: 179 | return False # not image 180 | return True # image 181 | 182 | def redraw_figures(self): 183 | """ Dummy function to redraw figures in the children classes """ 184 | pass 185 | 186 | # noinspection PyUnusedLocal 187 | def __scroll_x(self, *args, **kwargs): 188 | """ Scroll canvas horizontally and redraw the image """ 189 | self.canvas.xview(*args) # scroll horizontally 190 | self.__show_image() # redraw the image 191 | 192 | # noinspection PyUnusedLocal 193 | def __scroll_y(self, *args, **kwargs): 194 | """ Scroll canvas vertically and redraw the image """ 195 | self.canvas.yview(*args) # scroll vertically 196 | self.__show_image() # redraw the image 197 | 198 | def __show_image(self): 199 | """ Show image on the Canvas. Implements correct image zoom almost like in Google Maps """ 200 | 201 | if self.path == '' or self.busy: 202 | return 203 | 204 | box_image = self.canvas.coords(self.wrapper) # get image area 205 | box_canvas = (self.canvas.canvasx(0), # get visible area of the canvas 206 | self.canvas.canvasy(0), 207 | self.canvas.canvasx(self.canvas.winfo_width()), 208 | self.canvas.canvasy(self.canvas.winfo_height())) 209 | box_img_int = tuple(map(int, box_image)) # convert to integer or it will not work properly 210 | # Get scroll region box 211 | box_scroll = [min(box_img_int[0], box_canvas[0]), min(box_img_int[1], box_canvas[1]), 212 | max(box_img_int[2], box_canvas[2]), max(box_img_int[3], box_canvas[3])] 213 | #print("box_canvas", box_canvas) 214 | #print("box_scroll", box_scroll) 215 | # Horizontal part of the image is in the visible area 216 | if box_scroll[0] == box_canvas[0] and box_scroll[2] == box_canvas[2]: 217 | box_scroll[0] = box_img_int[0] 218 | box_scroll[2] = box_img_int[2] 219 | # Vertical part of the image is in the visible area 220 | if box_scroll[1] == box_canvas[1] and box_scroll[3] == box_canvas[3]: 221 | box_scroll[1] = box_img_int[1] 222 | box_scroll[3] = box_img_int[3] 223 | # Convert scroll region to tuple and to integer 224 | self.canvas.configure(scrollregion=tuple(map(int, box_scroll))) # set scroll region 225 | x1 = max(box_canvas[0] - box_image[0], 0) # get coordinates (x1,y1,x2,y2) of the image tile 226 | y1 = max(box_canvas[1] - box_image[1], 0) 227 | x2 = min(box_canvas[2], box_image[2]) - box_image[0] 228 | y2 = min(box_canvas[3], box_image[3]) - box_image[1] 229 | if int(x2 - x1) > 0 and int(y2 - y1) > 0: # show image if it in the visible area 230 | if self.__huge and self.__curr_img < 0: # show huge image, which does not fit in RAM 231 | h = int((y2 - y1) / self.imscale) # height of the tile band 232 | self.__tile[1][3] = h # set the tile band height 233 | self.__tile[2] = self.__offset + self.imwidth * int(y1 / self.imscale) * 3 234 | self.__image.close() 235 | self.__image = Image.open(self.path) # reopen / reset image 236 | self.__image.size = (self.imwidth, h) # set size of the tile band 237 | self.__image.tile = [self.__tile] 238 | image = self.__image.crop((int(x1 / self.imscale), 0, int(x2 / self.imscale), h)) 239 | else: # show normal image 240 | image = self.__pyramid[max(0, self.__curr_img)].crop( # crop current img from pyramid 241 | (int(x1 / self.__scale), int(y1 / self.__scale), 242 | int(x2 / self.__scale), int(y2 / self.__scale))) 243 | # 244 | imagetk = ImageTk.PhotoImage(image.resize((int(x2 - x1), int(y2 - y1)), self.__filter)) 245 | imageid = self.canvas.create_image(max(box_canvas[0], box_img_int[0]), 246 | max(box_canvas[1], box_img_int[1]), 247 | anchor='nw', image=imagetk) 248 | self.canvas.lower(imageid) # set image into background 249 | self.canvas.imagetk = imagetk # keep an extra reference to prevent garbage-collection 250 | 251 | def __move_from(self, event): 252 | """ Remember previous coordinates for scrolling with the mouse """ 253 | if not self.init_widget or self.busy: 254 | return 255 | self.canvas.scan_mark(event.x, event.y) 256 | 257 | def __move_to(self, event): 258 | """ Drag (move) canvas to the new position """ 259 | if not self.init_widget or self.busy: 260 | return 261 | self.canvas.scan_dragto(event.x, event.y, gain=1) 262 | self.__show_image() # zoom tile and show it on the canvas 263 | 264 | def outside(self, x, y): 265 | """ Checks if the point (x,y) is outside the image area """ 266 | bbox = self.canvas.coords(self.wrapper) # get image area 267 | if bbox[0] < x < bbox[2] and bbox[1] < y < bbox[3]: 268 | return False # point (x,y) is inside the image area 269 | else: 270 | return True # point (x,y) is outside the image area 271 | 272 | def __center_img(self): 273 | #box_canvas = (self.canvas.canvasx(self.canvas.winfo_width()),self.canvas.canvasy(self.canvas.winfo_height())) 274 | scale = 1.0 275 | # Respond to Linux (event.num) or Windows (event.delta) wheel event 276 | self.__imframe.update() 277 | target_scale = self.imheight / self.canvas.canvasy(self.canvas.winfo_height()) 278 | sk = round(math.log(target_scale)/math.log(self.__delta)) 279 | self.imscale /= math.pow(self.__delta, sk) 280 | scale /= math.pow(self.__delta, sk) 281 | # Take appropriate image from the pyramid 282 | k = self.imscale * self.__ratio # temporary coefficient 283 | self.__curr_img = min((-1) * int(math.log(k, self.__reduction)), len(self.__pyramid) - 1) 284 | self.__scale = k * math.pow(self.__reduction, max(0, self.__curr_img)) 285 | self.canvas.scale('all', 0, 0, scale, scale) # rescale all objects 286 | 287 | def __wheel(self, event): 288 | """ Zoom with mouse wheel """ 289 | if not self.init_widget or self.busy: 290 | return 291 | x = self.canvas.canvasx(event.x) # get coordinates of the event on the canvas 292 | y = self.canvas.canvasy(event.y) 293 | if self.outside(x, y): return # zoom only inside image area 294 | scale = 1.0 295 | # Respond to Linux (event.num) or Windows (event.delta) wheel event 296 | if event.num == 5 or event.delta == -120: # scroll down, zoom out, smaller 297 | if round(self.__min_side * self.imscale) < 30: return # image is less than 30 pixels 298 | self.imscale /= self.__delta 299 | scale /= self.__delta 300 | if event.num == 4 or event.delta == 120: # scroll up, zoom in, bigger 301 | i = float(min(self.canvas.winfo_width(), self.canvas.winfo_height()) >> 1) 302 | if i < self.imscale: return # 1 pixel is bigger than the visible area 303 | self.imscale *= self.__delta 304 | scale *= self.__delta 305 | # Take appropriate image from the pyramid 306 | k = self.imscale * self.__ratio # temporary coefficient 307 | self.__curr_img = min((-1) * int(math.log(k, self.__reduction)), len(self.__pyramid) - 1) 308 | self.__scale = k * math.pow(self.__reduction, max(0, self.__curr_img)) 309 | self.canvas.scale('all', x, y, scale, scale) # rescale all objects 310 | # Redraw some figures before showing image on the screen 311 | self.redraw_figures() # method for child classes 312 | self.__show_image() 313 | 314 | def crop(self, bbox): 315 | """ Crop rectangle from the image and return it """ 316 | if self.__huge: # image is huge and not totally in RAM 317 | band = bbox[3] - bbox[1] # width of the tile band 318 | self.__tile[1][3] = band # set the tile height 319 | self.__tile[2] = self.__offset + self.imwidth * bbox[1] * 3 # set offset of the band 320 | self.__image.close() 321 | self.__image = Image.open(self.path) # reopen / reset image 322 | self.__image.size = (self.imwidth, band) # set size of the tile band 323 | self.__image.tile = [self.__tile] 324 | return self.__image.crop((bbox[0], 0, bbox[2], band)) 325 | else: # image is totally in RAM 326 | return self.__pyramid[0].crop(bbox) 327 | 328 | def destroy(self): 329 | """ ImageFrame destructor """ 330 | self.__image.close() 331 | map(lambda i: i.close, self.__pyramid) # close all pyramid images 332 | del self.__pyramid[:] # delete pyramid list 333 | del self.__pyramid # delete pyramid variable 334 | self.canvas.destroy() 335 | self.__imframe.destroy() 336 | 337 | 338 | if __name__ == "main": 339 | tkroot = tk.Tk() 340 | tkroot.geometry("1000x600+200+200") 341 | tkroot.grid_columnconfigure(0, weight=1) 342 | tkroot.grid_rowconfigure(0, weight=1) 343 | image = CanvasImage(tkroot, "/home/pmbremer/Bilder/japan/LAR01756.JPG") 344 | image.grid(row=0,column=0) 345 | image._CanvasImage__imframe.update() 346 | image.canvas.update() 347 | image._CanvasImage__center_img() 348 | image._CanvasImage__show_image() 349 | tkroot.mainloop() -------------------------------------------------------------------------------- /layoutx/widgets/calendar.py: -------------------------------------------------------------------------------- 1 | # Original Author: Miguel Martinez Lopez# 2 | # Uncomment the next line to see my email 3 | # print("Author's email: %s"%"61706c69636163696f6e616d656469646140676d61696c2e636f6d".decode("hex")) 4 | # Modified by Bomberus 5 | # Licensed under the MIT License 6 | from .widget import Widget 7 | import tkinter as Tkinter 8 | from tkinter import ttk, StringVar 9 | import tkinter.font as tkFont 10 | import calendar 11 | import datetime 12 | from tkinter.constants import CENTER, LEFT, RIGHT, N, E, W, S 13 | 14 | """ 15 | These are the default bindings: 16 | Click button 1 on entry: Show calendar 17 | Click button 1 outsite calendar and entry: Hide calendar 18 | Escape: Hide calendar 19 | CTRL + PAGE UP: Move to the previous month. 20 | CTRL + PAGE DOWN: Move to the next month. 21 | CTRL + SHIFT + PAGE UP: Move to the previous year. 22 | CTRL + SHIFT + PAGE DOWN: Move to the next year. 23 | CTRL + LEFT: Move to the previous day. 24 | CTRL + RIGHT: Move to the next day. 25 | CTRL + UP: Move to the previous week. 26 | CTRL + DOWN: Move to the next week. 27 | CTRL + END: Close the datepicker and erase the date. 28 | CTRL + HOME: Move to the current month. 29 | CTRL + SPACE: Show date on calendar 30 | CTRL + Return: Set current selection to entry 31 | """ 32 | 33 | def get_calendar(locale, fwday): 34 | # instantiate proper calendar class 35 | if locale is None: 36 | return calendar.TextCalendar(fwday) 37 | else: 38 | return calendar.LocaleTextCalendar(fwday, locale) 39 | 40 | 41 | class Calendar(ttk.Frame): 42 | datetime = calendar.datetime.datetime 43 | timedelta = calendar.datetime.timedelta 44 | 45 | def __init__(self, 46 | master=None, 47 | year=None, 48 | month=None, 49 | firstweekday=calendar.MONDAY, 50 | locale=None, 51 | activebackground='#b1dcfb', 52 | activeforeground='black', 53 | selectbackground='#003eff', 54 | selectforeground='white', 55 | command=None, 56 | borderwidth=1, 57 | relief="solid", 58 | on_click_month_button=None, 59 | on_click_year_button=None): 60 | """ 61 | WIDGET OPTIONS 62 | 63 | locale, firstweekday, year, month, selectbackground, 64 | selectforeground, activebackground, activeforeground, 65 | command, borderwidth, relief, on_click_month_button 66 | """ 67 | 68 | if year is None: 69 | year = self.datetime.now().year 70 | 71 | if month is None: 72 | month = self.datetime.now().month 73 | 74 | self._selected_date = None 75 | 76 | self._sel_bg = selectbackground 77 | self._sel_fg = selectforeground 78 | 79 | self._act_bg = activebackground 80 | self._act_fg = activeforeground 81 | 82 | self.on_click_month_button = on_click_month_button 83 | self.on_click_year_button = on_click_year_button 84 | 85 | self._selection_is_visible = False 86 | self._command = command 87 | 88 | ttk.Frame.__init__(self, master, borderwidth=borderwidth, relief=relief) 89 | 90 | self.bind("", lambda event:self.event_generate('<>')) 91 | self.bind("", lambda event:self.event_generate('<>')) 92 | 93 | self._cal = get_calendar(locale, firstweekday) 94 | 95 | # custom ttk styles 96 | style = ttk.Style() 97 | style.layout('L.TButton', ( 98 | [('Button.focus', {'children': [('Button.leftarrow', None)]})] 99 | )) 100 | style.layout('R.TButton', ( 101 | [('Button.focus', {'children': [('Button.rightarrow', None)]})] 102 | )) 103 | 104 | self._font = tkFont.Font() 105 | 106 | # header frame and its widgets 107 | hframe = ttk.Frame(self) 108 | #self._header_var = StringVar() 109 | # Month 110 | self._month_var = StringVar() 111 | self._month_var.set("month") 112 | mframe = ttk.Frame(hframe) 113 | mframe.pack(side=LEFT) 114 | lmbtn = ttk.Button(mframe, style='L.TButton', command=self._on_press_left_month_button) 115 | lmbtn.pack(side=LEFT) 116 | 117 | mheader = ttk.Label(mframe, width=15, anchor=CENTER, textvariable=self._month_var) 118 | mheader.pack(side=LEFT, padx=12) 119 | 120 | rmbtn = ttk.Button(mframe, style='R.TButton', command=self._on_press_right_month_button) 121 | rmbtn.pack(side=LEFT) 122 | # Year 123 | self._year_var = StringVar() 124 | self._year_var.set("year") 125 | yframe = ttk.Frame(hframe) 126 | yframe.pack(side=LEFT) 127 | 128 | lybtn = ttk.Button(yframe, style='L.TButton', command=self._on_press_left_year_button) 129 | lybtn.pack(side=LEFT) 130 | 131 | yheader = ttk.Label(yframe, width=15, anchor=CENTER, textvariable=self._year_var) 132 | yheader.pack(side=LEFT, padx=12) 133 | 134 | rybtn = ttk.Button(yframe, style='R.TButton', command=self._on_press_right_year_button) 135 | rybtn.pack(side=LEFT) 136 | 137 | hframe.grid(columnspan=7, pady=4) 138 | 139 | self._day_labels = {} 140 | 141 | days_of_the_week = self._cal.formatweekheader(3).split() 142 | 143 | for i, day_of_the_week in enumerate(days_of_the_week): 144 | Tkinter.Label(self, text=day_of_the_week, background='grey90').grid(row=1, column=i, sticky=N+E+W+S) 145 | 146 | for i in range(6): 147 | for j in range(7): 148 | self._day_labels[i,j] = label = Tkinter.Label(self, background = "white") 149 | 150 | label.grid(row=i+2, column=j, sticky=N+E+W+S) 151 | label.bind("", lambda event: event.widget.configure(background=self._act_bg, foreground=self._act_fg)) 152 | label.bind("", lambda event: event.widget.configure(background="white")) 153 | 154 | label.bind("<1>", self._pressed) 155 | 156 | # adjust its columns width 157 | font = tkFont.Font() 158 | maxwidth = max(font.measure(text) for text in days_of_the_week) 159 | for i in range(7): 160 | self.grid_columnconfigure(i, minsize=maxwidth, weight=1) 161 | 162 | self._year = None 163 | self._month = None 164 | 165 | # insert dates in the currently empty calendar 166 | self._build_calendar(year, month) 167 | 168 | def _build_calendar(self, year, month): 169 | if not( self._year == year and self._month == month): 170 | self._year = year 171 | self._month = month 172 | 173 | # update header text (Month, YEAR) 174 | header = self._cal.formatmonthname(year, month, 0) 175 | self._year_var.set(self._year) 176 | self._month_var.set(self._month) 177 | #self._header_var.set(header.title()) 178 | 179 | # update calendar shown dates 180 | cal = self._cal.monthdayscalendar(year, month) 181 | 182 | for i in range(len(cal)): 183 | 184 | week = cal[i] 185 | fmt_week = [('%02d' % day) if day else '' for day in week] 186 | 187 | for j, day_number in enumerate(fmt_week): 188 | self._day_labels[i,j]["text"] = day_number 189 | 190 | if len(cal) < 6: 191 | for j in range(7): 192 | self._day_labels[5,j]["text"] = "" 193 | 194 | if self._selected_date is not None and self._selected_date.year == self._year and self._selected_date.month == self._month: 195 | self._show_selection() 196 | 197 | def _find_label_coordinates(self, date): 198 | first_weekday_of_the_month = (date.weekday() - date.day) % 7 199 | 200 | return divmod((first_weekday_of_the_month - self._cal.firstweekday)%7 + date.day, 7) 201 | 202 | def _show_selection(self): 203 | """Show a new selection.""" 204 | 205 | i,j = self._find_label_coordinates(self._selected_date) 206 | 207 | label = self._day_labels[i,j] 208 | 209 | label.configure(background=self._sel_bg, foreground=self._sel_fg) 210 | 211 | label.unbind("") 212 | label.unbind("") 213 | 214 | self._selection_is_visible = True 215 | 216 | def _clear_selection(self): 217 | """Show a new selection.""" 218 | i,j = self._find_label_coordinates(self._selected_date) 219 | 220 | label = self._day_labels[i,j] 221 | label.configure(background= "white", foreground="black") 222 | 223 | label.bind("", lambda event: event.widget.configure(background=self._act_bg, foreground=self._act_fg)) 224 | label.bind("", lambda event: event.widget.configure(background="white")) 225 | 226 | self._selection_is_visible = False 227 | 228 | # Callback 229 | 230 | def _pressed(self, evt): 231 | """Clicked somewhere in the calendar.""" 232 | 233 | text = evt.widget["text"] 234 | 235 | if text == "": 236 | return 237 | 238 | day_number = int(text) 239 | 240 | new_selected_date = datetime.datetime(self._year, self._month, day_number) 241 | if self._selected_date != new_selected_date: 242 | if self._selected_date is not None: 243 | self._clear_selection() 244 | 245 | self._selected_date = new_selected_date 246 | 247 | self._show_selection() 248 | 249 | if self._command: 250 | self._command(self._selected_date) 251 | 252 | def _on_press_left_month_button(self): 253 | self.prev_month() 254 | 255 | if self.on_click_month_button is not None: 256 | self.on_click_month_button() 257 | 258 | def _on_press_left_year_button(self): 259 | self.prev_year() 260 | 261 | if self.on_click_year_button is not None: 262 | self.on_click_year_button() 263 | 264 | 265 | def _on_press_right_month_button(self): 266 | self.next_month() 267 | 268 | if self.on_click_month_button is not None: 269 | self.on_click_month_button() 270 | 271 | def _on_press_right_year_button(self): 272 | self.next_year() 273 | 274 | if self.on_click_year_button is not None: 275 | self.on_click_year_button() 276 | 277 | def select_prev_day(self): 278 | """Updated calendar to show the previous day.""" 279 | if self._selected_date is None: 280 | self._selected_date = datetime.datetime(self._year, self._month, 1) 281 | else: 282 | self._clear_selection() 283 | self._selected_date = self._selected_date - self.timedelta(days=1) 284 | 285 | self._build_calendar(self._selected_date.year, self._selected_date.month) # reconstruct calendar 286 | 287 | def select_next_day(self): 288 | """Update calendar to show the next day.""" 289 | 290 | if self._selected_date is None: 291 | self._selected_date = datetime.datetime(self._year, self._month, 1) 292 | else: 293 | self._clear_selection() 294 | self._selected_date = self._selected_date + self.timedelta(days=1) 295 | 296 | self._build_calendar(self._selected_date.year, self._selected_date.month) # reconstruct calendar 297 | 298 | 299 | def select_prev_week_day(self): 300 | """Updated calendar to show the previous week.""" 301 | if self._selected_date is None: 302 | self._selected_date = datetime.datetime(self._year, self._month, 1) 303 | else: 304 | self._clear_selection() 305 | self._selected_date = self._selected_date - self.timedelta(days=7) 306 | 307 | self._build_calendar(self._selected_date.year, self._selected_date.month) # reconstruct calendar 308 | 309 | def select_next_week_day(self): 310 | """Update calendar to show the next week.""" 311 | if self._selected_date is None: 312 | self._selected_date = datetime.datetime(self._year, self._month, 1) 313 | else: 314 | self._clear_selection() 315 | self._selected_date = self._selected_date + self.timedelta(days=7) 316 | 317 | self._build_calendar(self._selected_date.year, self._selected_date.month) # reconstruct calendar 318 | 319 | def select_current_date(self): 320 | """Update calendar to current date.""" 321 | if self._selection_is_visible: self._clear_selection() 322 | 323 | self._selected_date = datetime.datetime.now() 324 | self._build_calendar(self._selected_date.year, self._selected_date.month) 325 | 326 | def prev_month(self): 327 | """Updated calendar to show the previous week.""" 328 | if self._selection_is_visible: self._clear_selection() 329 | 330 | date = self.datetime(self._year, self._month, 1) - self.timedelta(days=1) 331 | self._build_calendar(date.year, date.month) # reconstuct calendar 332 | 333 | def next_month(self): 334 | """Update calendar to show the next month.""" 335 | if self._selection_is_visible: self._clear_selection() 336 | 337 | date = self.datetime(self._year, self._month, 1) + \ 338 | self.timedelta(days=calendar.monthrange(self._year, self._month)[1] + 1) 339 | 340 | self._build_calendar(date.year, date.month) # reconstuct calendar 341 | 342 | def prev_year(self): 343 | """Updated calendar to show the previous year.""" 344 | 345 | if self._selection_is_visible: self._clear_selection() 346 | 347 | self._build_calendar(self._year-1, self._month) # reconstruct calendar 348 | 349 | def next_year(self): 350 | """Update calendar to show the next year.""" 351 | 352 | if self._selection_is_visible: self._clear_selection() 353 | 354 | self._build_calendar(self._year+1, self._month) # reconstruct calendar 355 | 356 | def get_selection(self): 357 | """Return a datetime representing the current selected date.""" 358 | return self._selected_date 359 | 360 | selection = get_selection 361 | 362 | def set_selection(self, date): 363 | """Set the selected date.""" 364 | if self._selected_date is not None and self._selected_date != date: 365 | self._clear_selection() 366 | 367 | self._selected_date = date 368 | 369 | self._build_calendar(date.year, date.month) # reconstruct calendar 370 | 371 | # see this URL for date format information: 372 | # https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior 373 | 374 | class CalendarWidget(Widget): 375 | def __init__(self, master, **kwargs): 376 | self._textv = StringVar() 377 | super().__init__(tk=Datepicker(master, datevar=self._textv, dateformat=kwargs.get("node").get_attr("dateformat", "%Y-%m-%d")), **kwargs) 378 | 379 | self._setter = self.connect_to_prop("value", self.on_changed_value) 380 | self._trace = self._textv.trace_add("write", 381 | lambda *_: self._setter(self._textv.get()) 382 | ) 383 | 384 | def on_changed_value(self, value): 385 | self._textv.set(value) 386 | 387 | def on_disposed(self): 388 | self._textv.trace_remove("write", self._trace) 389 | self._setter = None 390 | 391 | class Datepicker(ttk.Entry): 392 | def __init__(self, master, entrywidth=None, entrystyle=None, datevar=None, dateformat="%Y-%m-%d", onselect=None, firstweekday=calendar.MONDAY, locale=None, activebackground='#b1dcfb', activeforeground='black', selectbackground='#003eff', selectforeground='white', borderwidth=1, relief="solid"): 393 | self.date_var = datevar 394 | entry_config = {} 395 | if entrywidth is not None: 396 | entry_config["width"] = entrywidth 397 | 398 | if entrystyle is not None: 399 | entry_config["style"] = entrystyle 400 | 401 | ttk.Entry.__init__(self, master, textvariable=self.date_var, **entry_config) 402 | 403 | self.date_format = dateformat 404 | 405 | self._is_calendar_visible = False 406 | self._on_select_date_command = onselect 407 | 408 | self.calendar_frame = Calendar(self.winfo_toplevel(), firstweekday=firstweekday, locale=locale, activebackground=activebackground, activeforeground=activeforeground, selectbackground=selectbackground, selectforeground=selectforeground, command=self._on_selected_date, on_click_month_button=lambda: self.focus()) 409 | 410 | self.bind_all("<1>", self._on_click, "+") 411 | 412 | self.bind("", lambda event: self._on_entry_focus_out()) 413 | self.bind("", lambda event: self.hide_calendar()) 414 | self.calendar_frame.bind("<>", lambda event: self._on_calendar_focus_out()) 415 | 416 | 417 | # CTRL + PAGE UP: Move to the previous month. 418 | self.bind("", lambda event: self.calendar_frame.prev_month()) 419 | 420 | # CTRL + PAGE DOWN: Move to the next month. 421 | self.bind("", lambda event: self.calendar_frame.next_month()) 422 | 423 | # CTRL + SHIFT + PAGE UP: Move to the previous year. 424 | self.bind("", lambda event: self.calendar_frame.prev_year()) 425 | 426 | # CTRL + SHIFT + PAGE DOWN: Move to the next year. 427 | self.bind("", lambda event: self.calendar_frame.next_year()) 428 | 429 | # CTRL + LEFT: Move to the previous day. 430 | self.bind("", lambda event: self.calendar_frame.select_prev_day()) 431 | 432 | # CTRL + RIGHT: Move to the next day. 433 | self.bind("", lambda event: self.calendar_frame.select_next_day()) 434 | 435 | # CTRL + UP: Move to the previous week. 436 | self.bind("", lambda event: self.calendar_frame.select_prev_week_day()) 437 | 438 | # CTRL + DOWN: Move to the next week. 439 | self.bind("", lambda event: self.calendar_frame.select_next_week_day()) 440 | 441 | # CTRL + END: Close the datepicker and erase the date. 442 | self.bind("", lambda event: self.erase()) 443 | 444 | # CTRL + HOME: Move to the current month. 445 | self.bind("", lambda event: self.calendar_frame.select_current_date()) 446 | 447 | # CTRL + SPACE: Show date on calendar 448 | self.bind("", lambda event: self.show_date_on_calendar()) 449 | 450 | # CTRL + Return: Set to entry current selection 451 | self.bind("", lambda event: self.set_date_from_calendar()) 452 | 453 | def set_date_from_calendar(self): 454 | if self.is_calendar_visible: 455 | selected_date = self.calendar_frame.selection() 456 | 457 | if selected_date is not None: 458 | self.date_var.set(selected_date.strftime(self.date_format)) 459 | 460 | if self._on_select_date_command is not None: 461 | self._on_select_date_command(selected_date) 462 | 463 | self.hide_calendar() 464 | 465 | @property 466 | def current_text(self): 467 | return self.date_var.get() 468 | 469 | @current_text.setter 470 | def current_text(self, text): 471 | return self.date_var.set(text) 472 | 473 | @property 474 | def current_date(self): 475 | try: 476 | date = datetime.datetime.strptime(self.date_var.get(), self.date_format) 477 | return date 478 | except ValueError: 479 | return None 480 | 481 | @current_date.setter 482 | def current_date(self, date): 483 | self.date_var.set(date.strftime(self.date_format)) 484 | 485 | @property 486 | def is_valid_date(self): 487 | if self.current_date is None: 488 | return False 489 | else: 490 | return True 491 | 492 | def show_date_on_calendar(self): 493 | date = self.current_date 494 | if date is not None: 495 | self.calendar_frame.set_selection(date) 496 | 497 | self.show_calendar() 498 | 499 | def show_calendar(self): 500 | if not self._is_calendar_visible: 501 | self.calendar_frame.place(in_=self, relx=0, rely=1) 502 | self.calendar_frame.lift() 503 | 504 | self._is_calendar_visible = True 505 | 506 | def hide_calendar(self): 507 | if self._is_calendar_visible: 508 | self.calendar_frame.place_forget() 509 | 510 | self._is_calendar_visible = False 511 | 512 | def erase(self): 513 | self.hide_calendar() 514 | self.date_var.set("") 515 | 516 | @property 517 | def is_calendar_visible(self): 518 | return self._is_calendar_visible 519 | 520 | def _on_entry_focus_out(self): 521 | if not str(self.focus_get()).startswith(str(self.calendar_frame)): 522 | self.hide_calendar() 523 | 524 | def _on_calendar_focus_out(self): 525 | if self.focus_get() != self: 526 | self.hide_calendar() 527 | 528 | def _on_selected_date(self, date): 529 | self.date_var.set(date.strftime(self.date_format)) 530 | self.hide_calendar() 531 | 532 | if self._on_select_date_command is not None: 533 | self._on_select_date_command(date) 534 | 535 | def _on_click(self, event): 536 | str_widget = str(event.widget) 537 | 538 | if str_widget == str(self): 539 | if not self._is_calendar_visible: 540 | self.show_date_on_calendar() 541 | else: 542 | if not str_widget.startswith(str(self.calendar_frame)) and self._is_calendar_visible: 543 | self.hide_calendar() --------------------------------------------------------------------------------