Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,39 @@

## Unreleased

## 0.3.0

- Add the Basic editor. When you create a dashboard you choose the editor: Advanced is the
full editor you know and stays the default, Basic cuts the screen into regions and puts
one widget in each, with nothing to drag or align. A dashboard keeps the editor it was
made in. In Basic you set the grid (columns, rows, the gap and the outer padding),
click one cell and another to make a region of the cells between them, and double-click
a region to remove it. A region can be any rectangle of cells, from one cell to the whole
grid, and there is no limit to how many. Regions are lettered in the order you made them.
On the Widgets tab you drag a widget into a region, or click one to fill the selected
region, and its properties are on the right. Only empty regions are outlined, and a click
beside the regions lets go of the selection, so you see the picture as it will be
rendered.
- Mark each dashboard in the list as Basic or Advanced.
- Rebuild the New dashboard window: pick where the display comes from (a device, a
predefined display or a custom size), then the display and the name, and see the display
drawn in the proportions of its resolution. Two arrows turn it a quarter at a time, with a
small lean back and a settle at the end, and the canvas turns with it.
- Draw each dashboard in the list as its display: in the proportions of its resolution,
turned as it is set up, with the colors of its palette and its resolution at the bottom,
and cards no taller than they need to be.
- Add a setting to Display settings, "Media Source picture as designed". Off, as before,
the picture the Media Source gives is turned for the display. On, it keeps the
orientation you see in the designer, which suits a preview or a card; to show it with
`opendisplay.upload_image` give the rotation of the dashboard as `rotation`, which the
text under the switch names. What the editor sends to a device is always turned for it.
- Fix a text that starts outside the display and is turned: the Media Source and the
picture sent to a device lost its first letters while the editor showed it whole. The
display now receives the same pixels the editor shows, at every rotation.
- Fix the cells of a grid in widgets, which stopped short of the edge of their box by the
width of the gaps; the weather widget moved by a few pixels.
- Give Display settings a cog in the menu of a dashboard.

## 0.2.0

- Add a Settings view, opened from the list of dashboards, with the fonts, the widgets and
Expand Down
8 changes: 7 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,9 @@ dashboard document ─▶ validate ─▶ resolve widget data ─▶ resolve exp
(`preview_margin`; elements are shifted into it just before rendering), so what hangs
out of the display is seen (the empty margin is transparent) and the canvas outlines
the display.
Item bounds stay in display coordinates, and the Media Source picture has no margin.
Item bounds stay in display coordinates. The Media Source picture is drawn on that same
picture, margin and all, and the margin is cut off afterwards, so the display receives
exactly the pixels the editor shows (a test compares them, at every rotation).
- **The dashboard document is the only persisted state** (HA `Store`,
versioned). No `localStorage`/IndexedDB for dashboards. Per-viewer UI
conveniences (panel widths, collapsed sections, clipboard) may use memory or
Expand All @@ -160,6 +162,10 @@ dashboard document ─▶ validate ─▶ resolve widget data ─▶ resolve exp
tested with a stored fixture of the previous version. Never rely on "records
that fail validation are skipped".
- TypeScript types and Python validation change together, in one change.
- A dashboard has a `mode`, `advanced` (the default) or `basic`, chosen when it is created
and never changed. A basic dashboard has a `layout` (a grid and its regions) and widget
items with a `region`; the backend sets their frames from the layout. An advanced one has
neither. Never add a conversion between the modes.
- `items` is a **tree**. Sibling order is z-order: first drawn first; the last
child is on top. Item kinds: `primitive` (exactly one ODL element), `widget`
(zero or more ODL elements from its renderer), `container` (editor-side
Expand Down
70 changes: 67 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,11 @@ display can fetch.
## Start from the display you own

Create a dashboard from a display Home Assistant already knows, from a list of
common panels, or at any custom size. Rotation is part of the dashboard, so a
portrait display just works.
common panels, or at any custom size. Rotation is part of the dashboard: turn the
display in the preview and the canvas turns with it, so a portrait display just works.

<p align="center">
<img src="https://raw.githubusercontent.com/Misiu/OpenDisplay-Studio-Integration/main/docs/images/new-dashboard.webp" alt="The new dashboard dialog, starting from a device" width="760">
<img src="https://raw.githubusercontent.com/Misiu/OpenDisplay-Studio-Integration/main/docs/images/new-dashboard.webp" alt="The new dashboard dialog: a device, a name and a preview of the display that the arrows turn" width="560">
</p>

All your dashboards live in one list, with their size, colours and state
Expand All @@ -56,6 +56,70 @@ device with one click.
<img src="https://raw.githubusercontent.com/Misiu/OpenDisplay-Studio-Integration/main/docs/images/gallery.webp" alt="The list of dashboards" width="760">
</p>

## Two editors: Basic and Advanced

You choose the editor when you create a dashboard, and the dashboard keeps it.
**Advanced** is the full editor described below: place and arrange every element
freely. **Basic** cuts the screen into regions and puts one widget in each; there is
nothing to align, so a screen is made in a few clicks. Advanced is the default, and
the list of dashboards marks each one as Basic or Advanced. A dashboard is never
converted from one to the other.

<p align="center">
<img src="docs/images/basic-new-dashboard.webp" alt="The new dashboard dialog with the Basic editor chosen" width="400">
</p>

**1. Set the grid.** On the *Layout* tab pick the number of columns and rows, and the gap
between the cells.

<p align="center">
<img src="docs/images/basic-grid.webp" alt="A grid of three columns and two rows" width="900">
</p>

**2. Make the regions.** Click one cell, then another: the cells between them become one
region. A region that would be cut in two is refused. Double-click a region to remove it; its cells are free again. Regions are lettered A, B, C … in the order you made them.

<p align="center">
<img src="docs/images/basic-join.webp" alt="Choosing the second cell of a region" width="900">
</p>

<p align="center">
<img src="docs/images/basic-regions.webp" alt="Two joined regions, one of them selected" width="900">
</p>

**3. Fill them with widgets.** On the *Widgets* tab drag a widget into a region, or select
a region and click a widget. Selecting a widget shows its properties on the right; with no
tree and no alignment tools there is more room for them.

<p align="center">
<img src="docs/images/basic-widgets-empty.webp" alt="The Widgets tab with four empty regions" width="900">
</p>

<p align="center">
<img src="docs/images/basic-widgets.webp" alt="Four widgets in four regions, the weather widget selected" width="900">
</p>

Only empty regions are drawn with a border. Click beside the regions to let go of the
selection and you see the picture as it will be rendered.

<p align="center">
<img src="docs/images/basic-result.webp" alt="The four widgets with nothing selected, as they will be rendered" width="900">
</p>

There is no limit to the layout but the size of the grid: a region may be any rectangle
of cells, so a tall one, a square one and a row of single cells can share a screen.

<p align="center">
<img src="docs/images/basic-many-regions.webp" alt="A grid of six columns and four rows cut into ten regions of different sizes" width="900">
</p>

<p align="center">
<img src="docs/images/basic-gallery.webp" alt="The list of dashboards, each marked Basic or Advanced" width="900">
</p>

A Basic dashboard is rendered by the same `odl-renderer` as an Advanced one, and its image
address works the same way.

## Shapes you can shape

Select a polygon and drag its points. Click the **+** on an edge to add a point,
Expand Down
48 changes: 48 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ Tick a step's box in the same change that completes it.
| 9 | Widget catalog (Sensor card, Agenda, Weather, …) | 8 |
| 10 | Finishing | all |
| 11 | Import and export of dashboards | 1, 8 |
| 12 | Basic editor (a grid of regions, a widget in each) | 8, 9 |

Phases 3 and 5 may run in parallel once phase 2 has landed the inspector shell.
Phase 8 may start in parallel with phases 4–7.
Expand Down Expand Up @@ -947,6 +948,52 @@ and out of backups. Deliberately simple: export writes a file, import reads a fi

---

## Phase 12 — Basic editor

A second editor beside the full one. A dashboard is created in one of two **modes** and
keeps it: `advanced` (everything above, the default, and what a stored dashboard without a
`mode` is) or `basic`. There is no conversion in either direction. A basic dashboard is a
grid cut into **regions**, with one widget in each; nothing is dragged, resized or aligned.
It renders through the same pipeline as an advanced one, and everything that reaches a
display is still ODL drawn by `odl-renderer`.

- [x] **12.1 Model and validation.** `mode` (`basic` | `advanced`) and, for a basic
dashboard, `layout: { columns, rows, gap, regions }`; a widget item carries `region`. A
region is a rectangle of cells (`row`, `column`, `rowSpan`, `columnSpan`, any size up to
the whole grid), and the regions cover every cell exactly once. The backend derives each
widget's `frame` from its region (`basic_layout.py`, through the SDK's `grid()`), so the
layout is the one place that says where a widget is; `schemaVersion` stays 1 and the new
fields are optional. An advanced dashboard may not have a `layout` or a `region`; a basic
one holds widgets only, at most one to a region. `bootstrap` sends the limits of a grid
(`layoutLimits`) and `opendisplay_studio/layout_frames` returns where the cells and
regions of a layout are on a display, so the panel never lays a grid out itself.
- [x] **12.2 Choosing the editor.** The new-dashboard dialog starts with the editor
(Advanced by default), then where the display comes from (a device, a predefined display
or a custom size), the display and the name, a preview of the display with arrows that
turn it, and the advanced display options (advanced mode only). The list of dashboards
marks each card Basic or Advanced and opens it in its own editor.
- [x] **12.3 Display preview.** One drawing of a display (`ods-display-preview`) for the
cards and the dialog: the proportions of the resolution, the housing turned as the display
is set up, its light and button where they are on the device. In the dialog a change of
rotation is animated: a lean back, a swing past and a settle (`rotation-motion.ts`).
- [x] **12.4 Layout tab.** The left panel has the grid (columns, rows, gap, outer padding).
One click on a cell and one on another make a region of the cells between them; a region
that would cut another in two is refused. A double click removes a region. Regions are
lettered A, B, C … in the order they were made, with their size in cells. Resizing the
grid keeps the regions that still fit.
- [x] **12.5 Widgets tab.** The left panel is the library with widgets only. A widget goes
into a region by dragging, or into the selected one (or the first empty one) by a click.
Properties are on the right; there is no structure tree and no arrange bar, and the
canvas keeps its zoom bar. Only an empty region is drawn with a border; a click beside
the regions lets go of the selection, so what is left is the rendered picture.
- [x] **12.6 Tests.** Backend: validation (any rectangles, a region as wide as the grid,
gaps in the tiling refused), frames of a layout, compile and render of a basic dashboard.
Frontend unit: region operations and lettering, placing a widget, hit testing, display
shape and rotation motion. E2E: choosing the editor, the grid, joining and removing
regions, filling them, saving, the gallery marks, selection and borders, the preview.

---

## Deviations from LVGL (deliberate)

| LVGL behaviour | Ours | Why |
Expand All @@ -965,3 +1012,4 @@ and out of backups. Deliberately simple: export writes a file, import reads a fi
| Fixed 5 px grid | Grid step is the dashboard `snapSize`; all other thresholds as measured | Displays differ in resolution; user already sets snap size |
| — (not an LVGL matter) | Document `schemaVersion` stays 1 and shape changes ship without migrations until the first public release of the dashboard model | Pre-release; the model changes shape every phase (flat list → tree → containers) |
| One field holds either a literal or an expression | Item `expressions` map beside the literals | Literals keep their shape (typed TS/Python, `ha-form`), switching the `{}` toggle off restores the value without loss |
| — (not an LVGL matter) | A dashboard has a `mode`; a basic one is a grid of regions with a widget in each, edited in its own editor | A simpler way to make a screen for those who do not need free placement; no conversion between the editors |
130 changes: 130 additions & 0 deletions custom_components/opendisplay_studio/basic_layout.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
"""The layout of a basic dashboard: a grid cut into regions, a widget in each."""

from __future__ import annotations

from typing import Any, Final

from .odl import Box
from .sdk.layout import grid
from .validation import fail, integer, string

MIN_LINES: Final = 1
MAX_LINES: Final = 12
DEFAULT_GAP: Final = 8
MAX_GAP: Final = 64
MAX_ID_LENGTH: Final = 64

type Layout = dict[str, Any]

LAYOUT_LIMITS: Final = {
"minLines": MIN_LINES,
"maxLines": MAX_LINES,
"defaultGap": DEFAULT_GAP,
"maxGap": MAX_GAP,
}


def working_area(width: int, height: int, padding: int) -> Box:
"""Return what is left of the display inside its padding."""
return Box(padding, padding, width - 2 * padding, height - 2 * padding)


def grid_cells(area: Box, columns: int, rows: int, gap: int) -> list[Box]:
"""Return the cells of the grid in reading order, laid out by the SDK."""
return grid(area, columns, rows, gap)


def region_frame(
cells: list[Box], columns: int, region: dict[str, Any]
) -> dict[str, int]:
"""Return the frame of a region: from its first cell to its last."""
first = cells[region["row"] * columns + region["column"]]
last = cells[
(region["row"] + region["rowSpan"] - 1) * columns
+ region["column"]
+ region["columnSpan"]
- 1
]
return {
"x": first.x,
"y": first.y,
"width": last.x + last.width - first.x,
"height": last.y + last.height - first.y,
}


def _region(value: object, index: int, columns: int, rows: int) -> dict[str, Any]:
name = f"layout.regions[{index}]"
if not isinstance(value, dict):
fail(f"{name} must be an object")
region: dict[str, Any] = {
"id": string(value.get("id"), f"{name}.id", MAX_ID_LENGTH),
"row": integer(value.get("row"), f"{name}.row", 0, rows - 1),
"column": integer(value.get("column"), f"{name}.column", 0, columns - 1),
"rowSpan": integer(value.get("rowSpan"), f"{name}.rowSpan", 1, rows),
"columnSpan": integer(
value.get("columnSpan"), f"{name}.columnSpan", 1, columns
),
}
if (
region["row"] + region["rowSpan"] > rows
or region["column"] + region["columnSpan"] > columns
):
fail(f"{name} reaches beyond the grid")
return region


def _check_tiling(regions: list[dict[str, Any]], columns: int, rows: int) -> None:
"""Every cell of the grid belongs to exactly one region."""
owners = [[0] * columns for _ in range(rows)]
for region in regions:
for row in range(region["row"], region["row"] + region["rowSpan"]):
for column in range(
region["column"], region["column"] + region["columnSpan"]
):
owners[row][column] += 1
if any(count != 1 for line in owners for count in line):
fail("layout.regions must cover every cell of the grid exactly once")


def validate_layout(value: object) -> Layout:
"""Check the grid and its regions and return them in normal form."""
if not isinstance(value, dict):
fail("layout is required for a basic dashboard")
columns = integer(value.get("columns"), "layout.columns", MIN_LINES, MAX_LINES)
rows = integer(value.get("rows"), "layout.rows", MIN_LINES, MAX_LINES)
gap = integer(value.get("gap", DEFAULT_GAP), "layout.gap", 0, MAX_GAP)
raw = value.get("regions")
if not isinstance(raw, list) or not raw:
fail("layout.regions must be a list of regions")
regions = [_region(item, index, columns, rows) for index, item in enumerate(raw)]
if len({region["id"] for region in regions}) != len(regions):
fail("layout.regions must have unique ids")
_check_tiling(regions, columns, rows)
return {"columns": columns, "rows": rows, "gap": gap, "regions": regions}


def apply_layout(
items: list[dict[str, Any]], layout: Layout, display: dict[str, int]
) -> None:
"""
Put each widget of a basic dashboard in the frame of its region.

A basic dashboard holds widgets only, each in a region of its own. The frame a
client sent is replaced: the layout is the one place that says where a widget is.
"""
columns = layout["columns"]
area = working_area(display["width"], display["height"], display["padding"])
cells = grid_cells(area, columns, layout["rows"], layout["gap"])
regions = {region["id"]: region for region in layout["regions"]}
taken: set[str] = set()
for item in items:
if item["kind"] != "widget":
fail("a basic dashboard holds widgets only")
region_id = item.get("region")
if not isinstance(region_id, str) or region_id not in regions:
fail(f"{item['id']} needs a region of the layout")
if region_id in taken:
fail("a region holds one widget")
taken.add(region_id)
item["frame"] = region_frame(cells, columns, regions[region_id])
2 changes: 1 addition & 1 deletion custom_components/opendisplay_studio/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

DOMAIN = "opendisplay_studio"
NAME = "OpenDisplay Studio"
INTEGRATION_VERSION = "0.2.0"
INTEGRATION_VERSION = "0.3.0"

DEFAULT_WIDTH = 800
DEFAULT_HEIGHT = 480
Expand Down
Loading
Loading