feat: Infrastructure for automatic control widget creation for configuration fields
Change-Id: I87a0eb6ba5b2128c779f04c8971106b1c8fbd33f
diff --git a/dcs/config/__init__.py b/dcs/config/__init__.py
index 78d7149..d3e0959 100644
--- a/dcs/config/__init__.py
+++ b/dcs/config/__init__.py
@@ -4,4 +4,5 @@
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
-from .store import Store, TomlStore, default_store, ConfigObject
+from .store import Store, TomlStore, default_store, ConfigObject, ConfigControlFrame
+from .ui import ui_create
diff --git a/dcs/config/store.py b/dcs/config/store.py
index 84801ba..7905ead 100644
--- a/dcs/config/store.py
+++ b/dcs/config/store.py
@@ -8,20 +8,41 @@
import toml
import json
-from typing import Dict, AnyStr, Callable, Type, Any, Optional
+from typing import Dict, AnyStr, Callable, Type, Any, Optional, List
from pydantic import BaseModel, PrivateAttr
import appdirs
import os
import logging
+from tkinter import ttk
+from abc import abstractmethod, ABC
-class ConfigObject(BaseModel):
+class ConfigControlFrame(ttk.Frame):
+ ctrl_widgets: List[ttk.Widget]
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.ctrl_widgets = []
+
+ def add_widget(self, w: ttk.Widget) -> None:
+ self.ctrl_widgets.append(w)
+
+ def widgets_on_change(self, cb) -> None:
+ for w in self.ctrl_widgets:
+ w.listen_change(cb)
+
+
+class ConfigObject(BaseModel, ABC):
_KEY: AnyStr = PrivateAttr()
@classmethod
def get_key(cls) -> AnyStr:
return cls._KEY
+ @abstractmethod
+ def make_config_widget(self, parent: ttk.Widget) -> ConfigControlFrame:
+ ...
+
class Store:
__instances: Dict[str, Store] = {}
diff --git a/dcs/config/ui/__init__.py b/dcs/config/ui/__init__.py
new file mode 100644
index 0000000..4d3a5eb
--- /dev/null
+++ b/dcs/config/ui/__init__.py
@@ -0,0 +1,7 @@
+# SPDX-License-Identifier: MPL-2.0
+# Copyright (c) 2022 Philipp Le <philipp@philipple.de>.
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at https://mozilla.org/MPL/2.0/.
+
+from .ui_create import ui_create
diff --git a/dcs/config/ui/base.py b/dcs/config/ui/base.py
new file mode 100644
index 0000000..d61ba04
--- /dev/null
+++ b/dcs/config/ui/base.py
@@ -0,0 +1,80 @@
+# SPDX-License-Identifier: MPL-2.0
+# Copyright (c) 2022 Philipp Le <philipp@philipple.de>.
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at https://mozilla.org/MPL/2.0/.
+
+from __future__ import annotations
+
+from typing import Type, Callable, List, Optional
+from tkinter import ttk
+from pydantic import BaseModel
+
+
+class ConfigWidget:
+ MODEL_TYPE: Type
+ FLAVOUR: Optional[str] = None
+
+ def listen_change(self, cb: Callable[[BaseModel, str, ConfigWidget], None]) -> None:
+ raise NotImplementedError
+
+ def notify_change(self) -> None:
+ raise NotImplementedError
+
+ def sync_out(self) -> None:
+ raise NotImplementedError
+
+ def sync_in(self) -> None:
+ raise NotImplementedError
+
+ def set_value(self, val) -> None:
+ raise NotImplementedError
+
+ @classmethod
+ def make_ui_create_method(cls, tp: Type, name: str):
+ raise NotImplementedError
+
+ def get_widget(self) -> ttk.Widget:
+ raise NotImplementedError
+
+
+def make_config_widget_base(base: Type[ttk.Widget]) -> Type[ttk.Widget, ConfigWidget]:
+ # The Pythonic way for generics
+
+ class ConfigWidgetBase(base, ConfigWidget):
+ def __init__(self, model: BaseModel, model_key: str, tp: Type, parent, *args, **kwargs):
+ super().__init__(parent, *args, **kwargs)
+ self.parent = parent
+ self._listeners: List[Callable[[BaseModel, str, ConfigWidget], None]] = []
+ self.model = model
+ self.model_key = model_key
+ self.type_ = tp
+
+ def listen_change(self, cb: Callable[[BaseModel, str, ConfigWidget], None]) -> None:
+ self._listeners.append(cb)
+
+ def notify_change(self) -> None:
+ for cb in self._listeners:
+ cb(self.model, self.model_key, self)
+
+ def sync_out(self) -> None:
+ raise NotImplementedError
+
+ def sync_in(self) -> None:
+ raise NotImplementedError
+
+ @classmethod
+ def make_ui_create_method(cls, tp: Type, name: str):
+ def create(self: BaseModel, parent, *args, **kwargs):
+ widget = cls(self, name, tp, parent, *args, **kwargs)
+ widget._config_widget()
+ return widget
+ return create
+
+ def _config_widget(self) -> None:
+ pass
+
+ def get_widget(self) -> ttk.Widget:
+ return self
+
+ return ConfigWidgetBase
diff --git a/dcs/config/ui/ui_create.py b/dcs/config/ui/ui_create.py
new file mode 100644
index 0000000..1bff499
--- /dev/null
+++ b/dcs/config/ui/ui_create.py
@@ -0,0 +1,49 @@
+# SPDX-License-Identifier: MPL-2.0
+# Copyright (c) 2022 Philipp Le <philipp@philipple.de>.
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at https://mozilla.org/MPL/2.0/.
+
+from __future__ import annotations
+
+from typing import Type, List, Dict, Optional
+from pydantic import BaseModel
+
+from .base import ConfigWidget
+
+
+_CLASS_REGISTRY: List[Type[ConfigWidget]] = [
+]
+
+
+def _find_widget_classes(tp: Type, is_list: bool = False) -> Dict[Optional[str], Type[ConfigWidget]]:
+ widget_classes: Dict[Optional[str], Type[ConfigWidget]] = {}
+ if is_list:
+ raise NotImplementedError
+ else:
+ for cls in _CLASS_REGISTRY:
+ if issubclass(tp, cls.MODEL_TYPE):
+ widget_classes[cls.FLAVOUR] = cls
+ if len(widget_classes) == 0:
+ raise TypeError(f'No widget class for {repr(tp)} found.')
+ return widget_classes
+
+
+def _add_ui_create(model_cls: BaseModel, tp: Type[int], name: str, widget_type: Type[ConfigWidget],
+ flavour: Optional[str]):
+ suffix = f'_{flavour}' if flavour is not None else ''
+ setattr(model_cls, f'ui_create_{name}{suffix}', widget_type.make_ui_create_method(tp, name))
+
+
+def ui_create(cls: BaseModel):
+ for field in cls.__fields__.values():
+ name = field.name
+ tp = field.type_
+ is_list = isinstance(field.default, list)
+ try:
+ widget_classes = _find_widget_classes(tp, is_list)
+ for flavour, wgd_cls in widget_classes.items():
+ _add_ui_create(cls, tp, name, wgd_cls, flavour)
+ except TypeError as e:
+ print(e)
+ return cls
diff --git a/test/test_config_ui.py b/test/test_config_ui.py
new file mode 100644
index 0000000..688f3ca
--- /dev/null
+++ b/test/test_config_ui.py
@@ -0,0 +1,75 @@
+# SPDX-License-Identifier: MPL-2.0
+# Copyright (c) 2022 Philipp Le <philipp@philipple.de>.
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at https://mozilla.org/MPL/2.0/.
+
+import unittest
+from typing import List
+from pydantic import conint, confloat
+from tkinter import ttk
+from enum import IntEnum
+
+from dcs.config.store import Store, ConfigObject, ConfigControlFrame
+from dcs.config.ui import ui_create
+
+
+class ExampleSubFrame(ConfigControlFrame):
+ pass
+
+
+@ui_create
+class ExampleSub(ConfigObject):
+ id: conint(ge=0, lt=10000) = 1
+ title: str = 'test'
+
+ def make_config_widget(self, parent: ttk.Widget) -> ConfigControlFrame:
+ frm = ExampleSubFrame(parent)
+ return frm
+
+
+
+class ExampleIntEnum(IntEnum):
+ Option1 = 1
+ Option2 = 2
+
+
+@ui_create
+class ExampleConfig(ConfigObject):
+ _KEY = 'example'
+ id: conint(ge=0, lt=10) = 1
+ name: str = 'Hello'
+ sub: List[ExampleSub] = [
+ ExampleSub(id=1, title='item 1'),
+ ExampleSub(id=2, title='item 2'),
+ ]
+ int_option: ExampleIntEnum = ExampleIntEnum.Option2
+ flt: confloat(ge=0.0, lt=10.0, multiple_of=0.01) = 0.12
+
+
+class UITest(unittest.TestCase):
+ def test_deserialize(self):
+ in_data = {
+ 'example': {
+ 'id': 0,
+ 'name': 'Test1',
+ 'sub': [
+ {
+ 'id': 0,
+ 'title': 'Title1',
+ },
+ {
+ 'id': 1,
+ 'title': 'Title2',
+ },
+ ]
+ }
+ }
+
+ loaded_store = Store.load_obj(in_data, 'default')
+
+ store = Store.get_instance('default')
+ self.assertEqual(id(loaded_store), id(store))
+
+ cfg: ExampleConfig = store.get_config(ExampleConfig)
+ self.assertIn('ui_create_id', dir(cfg))