Round dashboards: shape and ring, the ring drawn and turned in every look, Knob preset and face guide in the editor

This commit is contained in:
Melvin Strobl
2026-10-14 14:23:55 +02:00
parent 9bed078c41
commit be3446cb16
21 changed files with 1447 additions and 57 deletions
+35
View File
@@ -90,6 +90,12 @@ INPUT_WIDGETS = {
"color",
}
#: What a round dashboard may draw on its ring: the widgets that are one value
#: along a range, which is what turning a knob's ring can set or a circle's
#: edge can show. Mirrored in the client
#: (``frontend/src/components/Dashboard/DashboardView.tsx``).
RING_WIDGETS = {"slider", "dropdown", "color", "gauge"}
#: What a colour widget puts on the wire, by the format it was configured for.
#: The default is what the Node-RED instance this ports already sends its
#: DMX encoders — ``[h, s, v]``, hue in degrees and the other two in percent —
@@ -475,6 +481,14 @@ class DashboardDef(BaseModel):
#: "unset" and the client falls back to its default.
canvas_width: int = Field(default=1920, ge=0, le=7680)
canvas_height: int = Field(default=1080, ge=0, le=4320)
#: ``round`` draws the dashboard in the circle inscribed in its canvas, for
#: a round screen such as a rotary knob. A circle has one size, so a round
#: canvas is square: its height is its width.
shape: Literal["rect", "round"] = "rect"
#: The widget a round dashboard draws along its edge instead of in the grid
#: — one of ``RING_WIDGETS``, by id — which a knob's ring turns. Empty
#: draws no ring.
ring: str = ""
#: A lucide icon name, drawn on the panel rail; empty falls back to two
#: letters of the title.
icon: str = ""
@@ -534,6 +548,24 @@ class DashboardDef(BaseModel):
)
return self
@model_validator(mode="after")
def _check_shape(self) -> DashboardDef:
"""Square a round canvas, and hold the ring to a widget it can draw."""
if self.shape == "round":
self.canvas_height = self.canvas_width
if not self.ring:
return self
if self.shape != "round":
raise ValueError("only a round dashboard has a ring")
widget = next((w for w in self.widgets if w.id == self.ring), None)
if widget is None:
raise ValueError(
f"the ring names '{self.ring}', which is not a widget here"
)
if widget.type not in RING_WIDGETS:
raise ValueError(f"a '{widget.type}' widget cannot be drawn on the ring")
return self
@property
def setting_messages(self) -> list[str]:
"""Every message a bound setting reads. Empty for a static dashboard."""
@@ -549,6 +581,9 @@ class DashboardDef(BaseModel):
"""
shapes = []
for widget in self.widgets:
# Drawn along the edge, so it takes no place in the grid.
if widget.id == self.ring:
continue
at = (
widget.layout.get("lg")
or widget.layout.get("md")
+21
View File
@@ -445,6 +445,27 @@ def test_a_dashboard_is_cut_into_a_sane_number_of_columns():
DashboardDef(name="house", columns=columns)
def test_a_round_dashboard_is_square_and_rings_one_of_its_own_widgets():
dial = WidgetDef(id="dial", type="slider", layout={"lg": Placement(x=1)})
stat = WidgetDef(id="stat", type="stat")
knob = DashboardDef(
name="knob",
shape="round",
canvas_width=360,
canvas_height=200,
ring="dial",
widgets=[dial, stat],
)
assert knob.canvas_height == 360
# Drawn along the edge, so the outline of the grid leaves it out.
assert [shape.type for shape in knob.footprint] == ["stat"]
for shape, ring in (("rect", "dial"), ("round", "gone"), ("round", "stat")):
with pytest.raises(ValueError):
DashboardDef(name="knob", shape=shape, ring=ring, widgets=[dial, stat])
def test_a_setting_refuses_a_message_it_cannot_carry():
"""The same rule a widget binding is held to, from the document alone."""
DashboardDef(