feat: Infrastructure for automatic control widget creation for configuration fields

Change-Id: I87a0eb6ba5b2128c779f04c8971106b1c8fbd33f
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