From c73a6987bbaf29f44f9bf6c8ea3768ab95223bf6 Mon Sep 17 00:00:00 2001
From: David Hammer <dhammer@mailbox.org>
Date: Wed, 10 Nov 2021 13:53:00 +0100
Subject: [PATCH] Improve scene generation code

---
 src/calng/CalibrationManager.py |   1 +
 src/calng/scenes.py             | 303 +++++++++++++++++++++-----------
 2 files changed, 203 insertions(+), 101 deletions(-)

diff --git a/src/calng/CalibrationManager.py b/src/calng/CalibrationManager.py
index 874f48d1..ae009f0e 100644
--- a/src/calng/CalibrationManager.py
+++ b/src/calng/CalibrationManager.py
@@ -318,6 +318,7 @@ class CalibrationManager(DeviceClientBase, Device):
             # Assumes there are correction devices known to manager
             scene_data = scenes.manager_device_overview_scene(
                 self.deviceId,
+                self.getDeviceSchema(),
                 self._correction_device_schema,
                 sorted(self._correction_device_ids))
             payload = Hash('success', True, 'name', name, 'data', scene_data)
diff --git a/src/calng/scenes.py b/src/calng/scenes.py
index efc14e04..19759af3 100644
--- a/src/calng/scenes.py
+++ b/src/calng/scenes.py
@@ -1,3 +1,4 @@
+import enum
 import textwrap
 
 import karathon
@@ -34,6 +35,14 @@ _type_to_line_editable = {
 }
 
 
+class Align(enum.Enum):
+    CENTER = enum.auto()
+    TOP = enum.auto()
+    BOTTOM = enum.auto()
+    LEFT = enum.auto()
+    RIGHT = enum.auto()
+
+
 def titled(title, width=8):
     def actual_decorator(component_class):
         # should this create subclass instead of mutating?
@@ -56,9 +65,9 @@ def titled(title, width=8):
         orig_height = component_class.height
 
         def new_height(self):
-            return orig_height(self) + BASE_INC
+            return orig_height.fget(self) + BASE_INC
 
-        component_class.height = new_height
+        component_class.height = property(fget=new_height)
 
         return component_class
 
@@ -68,16 +77,16 @@ def titled(title, width=8):
 def boxed(component_class):
     # should this create subclass instead of mutating?
     orig_render = component_class.render
-    orig_height = component_class.height
     orig_width = component_class.width
+    orig_height = component_class.height
 
     def new_render(self, x, y, *args, **kwargs):
         return [
             RectangleModel(
                 x=x,
                 y=y,
-                width=orig_width(self) + 2 * PADDING,
-                height=orig_height(self) + 2 * PADDING,
+                width=orig_width.fget(self) + 2 * PADDING,
+                height=orig_height.fget(self) + 2 * PADDING,
                 stroke="#000000",
             )
         ] + orig_render(self, x + PADDING, y + PADDING, *args, **kwargs)
@@ -85,18 +94,87 @@ def boxed(component_class):
     component_class.render = new_render
 
     def new_height(self):
-        return orig_height(self) + 2 * PADDING
+        return orig_height.fget(self) + 2 * PADDING
 
-    component_class.height = new_height
+    component_class.height = property(fget=new_height)
 
     def new_width(self):
-        return orig_width(self) + 2 * PADDING
+        return orig_width.fget(self) + 2 * PADDING
 
-    component_class.width = new_width
+    component_class.width = property(fget=new_width)
 
     return component_class
 
 
+class HorizontalLayout:
+    def __init__(self, children=None, padding=PADDING):
+        if children is None:
+            self.children = []
+        else:
+            self.children = children
+        self.padding = padding
+
+    def render(self, x, y, align=Align.TOP):
+        if align is not Align.TOP:
+            height = self.height
+        res = []
+        for child in self.children:
+            if align is Align.TOP:
+                res.extend(child.render(x, y))
+            elif align is Align.CENTER:
+                res.extend(child.render(x, y + (height - child.height) / 2))
+            elif align is Align.BOTTOM:
+                res.extend(child.render(x, y + (height - child.height)))
+            else:
+                raise ValueError(f"Invalid align {align} for HorizontalLayout")
+            x += child.width + self.padding
+        return res
+
+    @property
+    def width(self):
+        if not self.children:
+            return 0
+        return self.padding * (len(self.children) - 1) + sum(
+            c.width for c in self.children
+        )
+
+    @property
+    def height(self):
+        if not self.children:
+            return 0
+        return max(c.height for c in self.children)
+
+
+class VerticalLayout:
+    def __init__(self, children=None, padding=PADDING):
+        if children is None:
+            self.children = []
+        else:
+            self.children = children
+        self.padding = padding
+
+    def render(self, x, y):
+        res = []
+        for child in self.children:
+            res.extend(child.render(x, y))
+            y += child.height + self.padding
+        return res
+
+    @property
+    def width(self):
+        if not self.children:
+            return 0
+        return max(c.width for c in self.children)
+
+    @property
+    def height(self):
+        if not self.children:
+            return 0
+        return self.padding * (len(self.children) - 1) + sum(
+            c.height for c in self.children
+        )
+
+
 class MaybeEditableRow:
     def __init__(
         self,
@@ -146,13 +224,15 @@ class MaybeEditableRow:
                     height=BASE_INC,
                 )
 
-    def height(self):
-        return BASE_INC
-
+    @property
     def width(self):
         return self.label_width + self.display_width + self.edit_width
 
-    def render(self, x=None, y=None):
+    @property
+    def height(self):
+        return BASE_INC
+
+    def render(self, x, y):
         self.label.x = x
         self.label.y = y
         self.value_display.x = x + self.label_width
@@ -176,36 +256,44 @@ class MaybeEditableRow:
 @boxed
 class ConstantParameterColumn:
     def __init__(self, device_id, schema_hash, prefix="constantParameters"):
+        if "." in prefix:
+            self.extra_path_prefix = prefix[: prefix.rfind(".") + 1]
+        else:
+            self.extra_path_prefix = ""
         self.device_id = device_id
         self.rows = [
-            MaybeEditableRow(device_id, schema_hash, f"{prefix}.{key}")
+            MaybeEditableRow(
+                device_id,
+                schema_hash,
+                f"{prefix}.{key}",
+            )
             for key in schema_hash.get(prefix).getKeys()
         ]
+        self.load_button = DisplayCommandModel(
+            keys=[f"{self.device_id}.{self.extra_path_prefix}loadMostRecentConstants"],
+            width=10 * BASE_INC,
+            height=BASE_INC,
+        )
 
     def render(self, x, y):
         res = []
         y_offset = 0
         for row in self.rows:
             res.extend(row.render(x=x, y=y + y_offset))
-            y_offset += row.height()
-
-        res.append(
-            DisplayCommandModel(
-                keys=[f"{self.device_id}.loadMostRecentConstants"],
-                x=x + 7 * BASE_INC,
-                y=y + y_offset,
-                width=10 * BASE_INC,
-                height=BASE_INC,
-            )
-        )
+            y_offset += row.height
+        self.load_button.x = x + 7 * BASE_INC
+        self.load_button.y = y + y_offset
+        res.append(self.load_button)
 
         return res
 
-    def height(self):
-        return sum(row.height() for row in self.rows) + BASE_INC
-
+    @property
     def width(self):
-        return max(row.width() for row in self.rows)
+        return max(row.width for row in self.rows)
+
+    @property
+    def height(self):
+        return sum(row.height for row in self.rows) + BASE_INC
 
 
 class ConstantNode:
@@ -227,12 +315,14 @@ class ConstantNode:
         y += BASE_INC
         return [self.title, self.ampel]
 
-    def height(self):
-        return BASE_INC
-
+    @property
     def width(self):
         return 8 * BASE_INC
 
+    @property
+    def height(self):
+        return BASE_INC
+
 
 @titled("Found constants", width=6)
 @boxed
@@ -248,14 +338,16 @@ class FoundConstantsColumn:
         y_offset = 0
         for row in self.rows:
             res.extend(row.render(x, y + y_offset))
-            y_offset += row.height()
+            y_offset += row.height
         return res
 
-    def height(self):
-        return sum(row.height() for row in self.rows)
-
+    @property
     def width(self):
-        return max(row.width() for row in self.rows)
+        return max(row.width for row in self.rows)
+
+    @property
+    def height(self):
+        return sum(row.height for row in self.rows)
 
 
 class CorrectionStepNode:
@@ -265,14 +357,14 @@ class CorrectionStepNode:
         )
         self.checkbox_main = CheckBoxModel(
             keys=[f"{device_id}.{step_path}.enable"],
-            height=BASE_INC,
             width=BASE_INC,
+            height=BASE_INC,
             klass="EditableCheckBox",
         )
         self.checkbox_preview = CheckBoxModel(
             keys=[f"{device_id}.{step_path}.preview"],
-            height=BASE_INC,
             width=BASE_INC,
+            height=BASE_INC,
             klass="EditableCheckBox",
         )
 
@@ -287,23 +379,19 @@ class CorrectionStepNode:
         self.checkbox_preview.y = y
         return [self.label, self.checkbox_main, self.checkbox_preview]
 
-    def height(self):
-        return BASE_INC
-
+    @property
     def width(self):
         return 10 * BASE_INC
 
+    @property
+    def height(self):
+        return BASE_INC
+
 
 @titled("Correction steps", width=6)
 @boxed
 class CorrectionStepsColumn:
-    def __init__(
-        self, device_id, schema_hash, prefix="corrections", control_prefix=None
-    ):
-        # separate control prefix allows iterating device schema, but setting on manager
-        if control_prefix is None:
-            control_prefix = prefix
-
+    def __init__(self, device_id, schema_hash, prefix="corrections"):
         self.header_main = LabelModel(
             text="enable", width=3 * BASE_INC, height=BASE_INC
         )
@@ -311,7 +399,7 @@ class CorrectionStepsColumn:
             text="preview", width=3 * BASE_INC, height=BASE_INC
         )
         self.steps = [
-            CorrectionStepNode(device_id, f"{control_prefix}.{step_path}")
+            CorrectionStepNode(device_id, f"{prefix}.{step_path}")
             for step_path in schema_hash.get(prefix).getKeys()
         ]
 
@@ -326,14 +414,16 @@ class CorrectionStepsColumn:
             step.x = x
             step.y = y
             res.extend(step.render(x, y))
-            y += step.height()
+            y += step.height
         return res
 
+    @property
     def width(self):
-        return max(step.width() for step in self.steps)
+        return max(step.width for step in self.steps)
 
+    @property
     def height(self):
-        return sum(step.height() for step in self.steps) + BASE_INC
+        return sum(step.height for step in self.steps) + BASE_INC
 
 
 class ConstantLoadedAmpeln:
@@ -355,9 +445,11 @@ class ConstantLoadedAmpeln:
             for i, key in enumerate(self.keys)
         ]
 
+    @property
     def width(self):
         return BASE_INC * len(self.keys)
 
+    @property
     def height(self):
         return BASE_INC
 
@@ -425,9 +517,11 @@ class CorrectionDeviceStatus:
             self.status_log,
         ]
 
+    @property
     def width(self):
         return 14 * BASE_INC
 
+    @property
     def height(self):
         return 23 * BASE_INC
 
@@ -475,9 +569,11 @@ class CompactCorrectionDeviceOverview:
         x += self.tid.width
         return [self.name, self.status, self.rate, self.tid] + self.ampeln.render(x, y)
 
+    @property
     def width(self):
-        return 19 * BASE_INC + self.ampeln.width()
+        return 19 * BASE_INC + self.ampeln.width
 
+    @property
     def height(self):
         return BASE_INC
 
@@ -488,63 +584,68 @@ def correction_device_overview_scene(device_id, schema):
     else:
         schema_hash = schema.hash
 
-    status_overview = CorrectionDeviceStatus(device_id)
-    cpc = ConstantParameterColumn(device_id, schema_hash)
-    fcc = FoundConstantsColumn(device_id, schema_hash)
-    csc = CorrectionStepsColumn(device_id, schema_hash)
-
-    subscenes = []
-    x = PADDING
-    y = PADDING
-    subscenes.extend(status_overview.render(x, y))
-    x += status_overview.width() + PADDING
-    subscenes.extend(cpc.render(x, y))
-    x += cpc.width() + PADDING
-    subscenes.extend(fcc.render(x, y))
-    y += fcc.height() + PADDING
-    subscenes.extend(csc.render(x, y))
+    content = HorizontalLayout(
+        children=[
+            CorrectionDeviceStatus(device_id),
+            ConstantParameterColumn(device_id, schema_hash),
+            VerticalLayout(
+                children=[
+                    FoundConstantsColumn(device_id, schema_hash),
+                    CorrectionStepsColumn(device_id, schema_hash),
+                ]
+            ),
+        ]
+    )
 
     scene = SceneModel(
-        height=max(
-            status_overview.height(),
-            cpc.height(),
-            fcc.height() + PADDING + csc.height(),
-        )
-        + 2 * PADDING,
-        width=2 * PADDING
-        + 2 * PADDING
-        + status_overview.width()
-        + cpc.width()
-        + csc.width(),
-        children=subscenes,
+        children=content.render(PADDING, PADDING),
+        width=content.width + 2 * PADDING,
+        height=content.height + 2 * PADDING,
     )
     return write_scene(scene)
 
 
 def manager_device_overview_scene(
-    manager_device_id, correction_device_schema, correction_device_ids
+    manager_device_id,
+    manager_device_schema,
+    correction_device_schema,
+    correction_device_ids,
 ):
-    if isinstance(correction_device_schema, karathon.Schema):
-        schema_hash = correction_device_schema.getParameterHash()
-    else:
-        schema_hash = correction_device_schema.hash
-
-    x = PADDING
-    y = PADDING
-    subscenes = []
-    correction_steps = CorrectionStepsColumn(
-        manager_device_id, schema_hash, control_prefix="managed.corrections"
+    mds_hash = schema_to_hash(manager_device_schema)
+    cds_hash = schema_to_hash(correction_device_schema)
+
+    content = VerticalLayout(
+        children=[
+            HorizontalLayout(
+                children=[
+                    ConstantParameterColumn(
+                        manager_device_id, mds_hash, prefix="managed.constantParameters"
+                    ),
+                    CorrectionStepsColumn(
+                        manager_device_id, mds_hash, prefix="managed.corrections"
+                    ),
+                ]
+            ),
+            VerticalLayout(
+                children=[
+                    CompactCorrectionDeviceOverview(device_id, cds_hash)
+                    for device_id in correction_device_ids
+                ],
+                padding=0,
+            ),
+        ]
     )
-    subscenes.extend(correction_steps.render(x, y))
-    y += correction_steps.height() + PADDING
-    for device_id in correction_device_ids:
-        ccdo = CompactCorrectionDeviceOverview(device_id, schema_hash)
-        subscenes.extend(ccdo.render(x, y))
-        y += ccdo.height()
 
     scene = SceneModel(
-        height=y + PADDING,
-        width=ccdo.width() + 2 * PADDING,
-        children=subscenes,
+        width=content.width + 2 * PADDING,
+        height=content.height + 2 * PADDING,
+        children=content.render(PADDING, PADDING),
     )
     return write_scene(scene)
+
+
+def schema_to_hash(schema):
+    if isinstance(schema, karathon.Schema):
+        return schema.getParameterHash()
+    else:
+        return schema.hash
-- 
GitLab