feat: Control widget for lists

Change-Id: Ic5c2d60320b0bbc426e2f337d56f3383544f26ac
diff --git a/dcs/config/ui/list_widget.py b/dcs/config/ui/list_widget.py
new file mode 100644
index 0000000..ba6ca37
--- /dev/null
+++ b/dcs/config/ui/list_widget.py
@@ -0,0 +1,160 @@
+#  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, List, Callable, AnyStr, Optional
+from tkinter import ttk, Listbox, X, LEFT, ACTIVE
+from tkinter.simpledialog import Dialog
+from pydantic import BaseModel
+from ..store import ConfigObject
+
+from .base import make_config_widget_base
+
+
+Entry2StrCb = Callable[[ConfigObject], AnyStr]
+
+
+class ListBoxWidget(make_config_widget_base(ttk.Frame)):
+    MODEL_TYPE = list
+    FLAVOUR = 'list'
+
+    def __init__(self, model: BaseModel, model_key: str, tp: Type, parent, translate_cb: Entry2StrCb, *args, **kwargs):
+        super().__init__(model, model_key, tp, parent, *args, **kwargs)
+
+        self.translate_cb: Entry2StrCb = translate_cb
+
+        self._box = Listbox(self)
+        self._box.bind('<Double-Button-1>', self._on_double_click)
+        self._box.pack(fill=X)
+
+        self._add_btn = ttk.Button(self, text='+ Add', command=self._on_add)
+        self._add_btn.pack(side=LEFT, padx=5, pady=5)
+
+        self._remove_btn = ttk.Button(self, text='- Remove', command=self._on_remove)
+        self._remove_btn.pack(side=LEFT, padx=5, pady=5)
+
+        self._up_btn = ttk.Button(self, text='^ Up', command=self._on_up)
+        self._up_btn.pack(side=LEFT, padx=5, pady=5)
+
+        self._down_btn = ttk.Button(self, text='v Down', command=self._on_down)
+        self._down_btn.pack(side=LEFT, padx=5, pady=5)
+
+    def sync_out(self) -> None:
+        self.notify_change()
+
+    def sync_in(self) -> None:
+        self._box.delete(0, self._box.size())
+
+        l = getattr(self.model, self.model_key)
+        for e in l:
+            self._box.insert('end', self.translate_cb(e))
+
+    def _config_widget(self):
+        self.sync_in()
+
+    def _run_dialog(self, index: Optional[int]):
+        l: List[ConfigObject] = getattr(self.model, self.model_key)
+        tp = self.type_
+
+        class EditDialog(Dialog):
+            def __init__(self, index, *args, **kwargs):
+                self.index = index
+                super().__init__(*args, **kwargs)
+
+            def body(self, master) -> None:
+                if self.index is not None:
+                    self.item = None
+                    w = l[self.index].make_config_widget(master)
+                else:
+                    self.item = tp()
+                    w = self.item.make_config_widget(master)
+                w.pack()
+
+            def buttonbox(self):
+                box = ttk.Frame(self)
+
+                w = ttk.Button(box, text="OK", width=10, command=self.ok, default=ACTIVE)
+                w.pack(side=LEFT, padx=5, pady=5)
+
+                self.bind("<Return>", self.ok)
+
+                if self.index is None:
+                    w = ttk.Button(box, text="Cancel", width=10, command=self.cancel)
+                    w.pack(side=LEFT, padx=5, pady=5)
+
+                    self.bind("<Escape>", self.cancel)
+
+                box.pack()
+
+            def apply(self):
+                if self.index is None:
+                    l.append(self.item)
+
+        EditDialog(index, self)
+        setattr(self.model, self.model_key, l)
+        self.notify_change()
+        self.sync_in()
+
+    def _on_add(self):
+        self._run_dialog(None)
+
+        last = len(getattr(self.model, self.model_key))
+        self._box.selection_set(last - 1)
+        self._box.see(last - 1)
+        self._box.activate(last - 1)
+        self._box.selection_anchor(last - 1)
+
+    def _on_double_click(self, _):
+        index = self._box.curselection()
+        if len(index) > 0:
+            self._run_dialog(index[0])
+
+    def _on_remove(self):
+        l: List[ConfigObject] = getattr(self.model, self.model_key)
+
+        index = self._box.curselection()
+        if len(index) > 0:
+            l.pop(index[0])
+
+            setattr(self.model, self.model_key, l)
+            self.notify_change()
+            self.sync_in()
+
+            self._box.selection_set(index[0] - 1)
+            self._box.see(index[0] - 1)
+            self._box.activate(index[0] - 1)
+            self._box.selection_anchor(index[0] - 1)
+
+    def _on_up(self):
+        l: List[ConfigObject] = getattr(self.model, self.model_key)
+
+        index = self._box.curselection()
+        if len(index) > 0 and index[0] > 0:
+            l.insert(index[0] - 1, l.pop(index[0]))
+
+            setattr(self.model, self.model_key, l)
+            self.sync_in()
+            self.notify_change()
+
+            self._box.selection_set(index[0] - 1)
+            self._box.see(index[0] - 1)
+            self._box.activate(index[0] - 1)
+            self._box.selection_anchor(index[0] - 1)
+
+    def _on_down(self):
+        l: List[ConfigObject] = getattr(self.model, self.model_key)
+
+        index = self._box.curselection()
+        if len(index) > 0 and index[0] < len(l):
+            l.insert(index[0] + 1, l.pop(index[0]))
+
+            setattr(self.model, self.model_key, l)
+            self.sync_in()
+            self.notify_change()
+            self._box.selection_set(index[0] + 1)
+            self._box.see(index[0] + 1)
+            self._box.activate(index[0] + 1)
+            self._box.selection_anchor(index[0] + 1)
diff --git a/dcs/config/ui/ui_create.py b/dcs/config/ui/ui_create.py
index da3f935..ec6ba23 100644
--- a/dcs/config/ui/ui_create.py
+++ b/dcs/config/ui/ui_create.py
@@ -13,6 +13,7 @@
 from .num_widget import NumWidget
 from .str_widget import StrWidget
 from .enum_widget import EnumDropdownWidget
+from .list_widget import ListBoxWidget
 
 
 _CLASS_REGISTRY: List[Type[ConfigWidget]] = [
@@ -25,7 +26,7 @@
 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
+        widget_classes[ListBoxWidget.FLAVOUR] = ListBoxWidget
     else:
         for cls in _CLASS_REGISTRY:
             if issubclass(tp, cls.MODEL_TYPE):
diff --git a/test/test_config_ui.py b/test/test_config_ui.py
index 77b5ddd..cccc3a4 100644
--- a/test/test_config_ui.py
+++ b/test/test_config_ui.py
@@ -136,3 +136,11 @@
         wdg.listen_change(lambda x, y, z: self._print_cfg())
 
         self.assertTrue(self._run_ui())
+
+    def test_list_widget(self):
+        cfg = self.store.get_config(ExampleConfig)
+        wdg = cfg.ui_create_sub_list(self.frm, lambda e: e.title)
+        wdg.pack()
+        wdg.listen_change(lambda x, y, z: self._print_cfg())
+
+        self.assertTrue(self._run_ui())