feat: Control widget for numeric fields

Change-Id: I00680f69dab06dedafde990dba3de1eeb994164a
diff --git a/dcs/config/ui/num_widget.py b/dcs/config/ui/num_widget.py
new file mode 100644
index 0000000..7652469
--- /dev/null
+++ b/dcs/config/ui/num_widget.py
@@ -0,0 +1,62 @@
+#  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 typing import Type
+from tkinter import ttk, StringVar
+from pydantic import ConstrainedInt, ConstrainedFloat
+
+from .base import make_config_widget_base
+
+
+class NumWidget(make_config_widget_base(ttk.Spinbox)):
+    MODEL_TYPE = int | float
+
+    def sync_out(self) -> None:
+        try:
+            setattr(self.model, self.model_key, self.type_(self._stringvar.get()))
+            self.notify_change()
+        except ValueError:
+            # Tolerate temporarily invalid values
+            pass
+
+    def sync_in(self) -> None:
+        val = getattr(self.model, self.model_key)
+        self._stringvar.set(str(val))
+
+    def _config_widget(self) -> None:
+        self._stringvar = StringVar(self.parent)
+        self.sync_in()
+        self.configure(textvariable=self._stringvar, command=lambda: self.sync_out())
+        self.bind('<KeyRelease>', lambda e: self.sync_out())
+        if issubclass(self.type_, ConstrainedInt):
+            tp: Type[ConstrainedInt] = self.type_
+            self.configure(increment=1)
+            if tp.gt is not None:
+                self.configure(from_=tp.gt + 1)
+            if tp.ge is not None:
+                self.configure(from_=tp.ge)
+            if tp.lt is not None:
+                self.configure(to=tp.lt - 1)
+            if tp.le is not None:
+                self.configure(to=tp.le)
+        elif issubclass(self.type_, ConstrainedFloat):
+            tp: Type[ConstrainedFloat] = self.type_
+            self.configure(increment=tp.multiple_of)
+            if tp.gt is not None:
+                self.configure(from_=tp.gt)
+            if tp.ge is not None:
+                self.configure(from_=tp.ge)
+            if tp.lt is not None:
+                self.configure(to=tp.lt)
+            if tp.le is not None:
+                self.configure(to=tp.le)
+        elif issubclass(self.type_, float):
+            raise TypeError(f'Please use ConstrainedFloat for {self.model_key}')
+        elif issubclass(self.type_, int):
+            raise TypeError(f'Please use ConstrainedInt for {self.model_key}')
+        else:
+            print(f'Unknown type f{repr(self.type_)}')
diff --git a/dcs/config/ui/ui_create.py b/dcs/config/ui/ui_create.py
index 1bff499..16d4ede 100644
--- a/dcs/config/ui/ui_create.py
+++ b/dcs/config/ui/ui_create.py
@@ -10,9 +10,11 @@
 from pydantic import BaseModel
 
 from .base import ConfigWidget
+from .num_widget import NumWidget
 
 
 _CLASS_REGISTRY: List[Type[ConfigWidget]] = [
+    NumWidget,
 ]
 
 
diff --git a/test/test_config_ui.py b/test/test_config_ui.py
index 688f3ca..7b23027 100644
--- a/test/test_config_ui.py
+++ b/test/test_config_ui.py
@@ -7,15 +7,16 @@
 import unittest
 from typing import List
 from pydantic import conint, confloat
-from tkinter import ttk
+from tkinter import Tk, ttk
 from enum import IntEnum
+import toml
 
 from dcs.config.store import Store, ConfigObject, ConfigControlFrame
 from dcs.config.ui import ui_create
 
 
 class ExampleSubFrame(ConfigControlFrame):
-    pass
+    wdg_id: ttk.Spinbox
 
 
 @ui_create
@@ -25,6 +26,10 @@
 
     def make_config_widget(self, parent: ttk.Widget) -> ConfigControlFrame:
         frm = ExampleSubFrame(parent)
+        frm.pack()
+        ttk.Label(frm, text="Id:").grid(row=0, column=0)
+        frm.wdg_id = self.ui_create_id(frm)
+        frm.wdg_id.grid(row=0, column=1)
         return frm
 
 
@@ -73,3 +78,49 @@
 
         cfg: ExampleConfig = store.get_config(ExampleConfig)
         self.assertIn('ui_create_id', dir(cfg))
+
+
+class UIInteractiveTest(unittest.TestCase):
+    def setUp(self) -> None:
+        self.root = Tk()
+        self.frm = ttk.Frame(self.root, padding=10)
+        self.frm.pack()
+
+        self.store = Store.new('default')
+
+    def _print_cfg(self) -> None:
+        print(repr(self.store.get_config(ExampleConfig)))
+        print(toml.dumps(self.store.dump_obj()))
+        #print(self.store.get_config(ExampleConfig).json())
+
+    def _run_ui(self) -> bool:
+        self.res = False
+
+        def act(ok: bool) -> None:
+            self.root.destroy()
+            self.res = ok
+
+        self.ok_btn = ttk.Button(self.root, text='Test OK', command=lambda: act(True))
+        self.ok_btn.pack()
+        self.ok_fail = ttk.Button(self.root, text='Test Fail', command=lambda: act(False))
+        self.ok_fail.pack()
+
+        self.root.mainloop()
+
+        return self.res
+
+    def test_int_widget(self):
+        cfg = self.store.get_config(ExampleConfig)
+        wdg = cfg.ui_create_id(self.frm)
+        wdg.pack()
+        wdg.listen_change(lambda x, y, z: self._print_cfg())
+
+        self.assertTrue(self._run_ui())
+
+    def test_float_widget(self):
+        cfg = self.store.get_config(ExampleConfig)
+        wdg = cfg.ui_create_flt(self.frm)
+        wdg.pack()
+        wdg.listen_change(lambda x, y, z: self._print_cfg())
+
+        self.assertTrue(self._run_ui())