diff --git a/CHANGELOG.md b/CHANGELOG.md index 3be5db4..d1173e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 3306b36..8127400 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -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 diff --git a/README.md b/README.md index 21c10f9..fd7a66e 100644 --- a/README.md +++ b/README.md @@ -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.

- The new dashboard dialog, starting from a device + The new dashboard dialog: a device, a name and a preview of the display that the arrows turn

All your dashboards live in one list, with their size, colours and state @@ -56,6 +56,70 @@ device with one click. The list of dashboards

+## 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. + +

+ The new dashboard dialog with the Basic editor chosen +

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

+ A grid of three columns and two rows +

+ +**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. + +

+ Choosing the second cell of a region +

+ +

+ Two joined regions, one of them selected +

+ +**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. + +

+ The Widgets tab with four empty regions +

+ +

+ Four widgets in four regions, the weather widget selected +

+ +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. + +

+ The four widgets with nothing selected, as they will be rendered +

+ +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. + +

+ A grid of six columns and four rows cut into ten regions of different sizes +

+ +

+ The list of dashboards, each marked Basic or Advanced +

+ +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, diff --git a/ROADMAP.md b/ROADMAP.md index afa0557..bb524a9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. @@ -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 | @@ -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 | diff --git a/custom_components/opendisplay_studio/basic_layout.py b/custom_components/opendisplay_studio/basic_layout.py new file mode 100644 index 0000000..b97c06a --- /dev/null +++ b/custom_components/opendisplay_studio/basic_layout.py @@ -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]) diff --git a/custom_components/opendisplay_studio/const.py b/custom_components/opendisplay_studio/const.py index 31564ea..a6d6824 100644 --- a/custom_components/opendisplay_studio/const.py +++ b/custom_components/opendisplay_studio/const.py @@ -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 diff --git a/custom_components/opendisplay_studio/dashboards.py b/custom_components/opendisplay_studio/dashboards.py index 496c956..7ef86c9 100644 --- a/custom_components/opendisplay_studio/dashboards.py +++ b/custom_components/opendisplay_studio/dashboards.py @@ -13,12 +13,14 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.storage import Store +from .basic_layout import apply_layout, validate_layout from .const import SCHEMA_VERSION, STORAGE_KEY, STORAGE_VERSION from .items import validate_items from .palette import PALETTE_COLORS from .primitives import DEFAULT_PRIMITIVES, PrimitiveRegistry from .validation import ( DashboardValidationError, + boolean, color, device_id, integer, @@ -33,6 +35,8 @@ MAX_NAME_LENGTH = 100 LANGUAGE_PATTERN = re.compile(r"^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$") PALETTES = frozenset(PALETTE_COLORS) +# Advanced: a free canvas. Basic: a grid of regions with a widget in each. +MODES = frozenset({"advanced", "basic"}) def validate_dashboard( @@ -57,6 +61,9 @@ def validate_dashboard( if padding * 2 >= min(width, height): raise DashboardValidationError("display.padding leaves no working area") snap_size = integer(display.get("snapSize", 5), "display.snapSize", 1, 256) + media_as_designed = boolean( + display.get("mediaAsDesigned", False), "display.mediaAsDesigned" + ) items = validate_items(value.get("items", []), registry, primitives, width, height) language = value.get("language", "en") if not isinstance(language, str) or LANGUAGE_PATTERN.fullmatch(language) is None: @@ -64,10 +71,15 @@ def validate_dashboard( status = value.get("status", "draft") if status not in {"draft", "ready"}: raise DashboardValidationError("status must be draft or ready") + # A dashboard stored before the modes existed is an advanced one. + mode = value.get("mode", "advanced") + if mode not in MODES: + raise DashboardValidationError("mode must be advanced or basic") normalized: Dashboard = { "schemaVersion": SCHEMA_VERSION, "name": string(value.get("name"), "name", MAX_NAME_LENGTH), "status": status, + "mode": mode, "language": language, "display": { "profileId": display.get("profileId") @@ -83,15 +95,29 @@ def validate_dashboard( "snapSize": snap_size, "rotation": rotation(display.get("rotation", 0)), "deviceId": device_id(display.get("deviceId")), + "mediaAsDesigned": media_as_designed, }, "items": items, } + _apply_mode(normalized, value) for key in ("id", "createdAt", "updatedAt"): if key in value: normalized[key] = value[key] return normalized +def _apply_mode(normalized: Dashboard, value: dict[str, Any]) -> None: + """Check what only a basic dashboard has, and put its widgets in their regions.""" + if normalized["mode"] == "advanced": + if "layout" in value: + raise DashboardValidationError("layout belongs to a basic dashboard") + if any("region" in item for item in normalized["items"]): + raise DashboardValidationError("region belongs to a basic dashboard") + return + normalized["layout"] = validate_layout(value.get("layout")) + apply_layout(normalized["items"], normalized["layout"], normalized["display"]) + + class DashboardStore: """Serialize dashboard mutations and persist them through HA Store.""" diff --git a/custom_components/opendisplay_studio/delivery.py b/custom_components/opendisplay_studio/delivery.py index 4ef05ce..004a47d 100644 --- a/custom_components/opendisplay_studio/delivery.py +++ b/custom_components/opendisplay_studio/delivery.py @@ -39,7 +39,8 @@ def preview_margin(display: dict[str, Any]) -> int: Elements may lie partly outside the display; seeing them makes moving them back easy. A margin of two fifths of the shorter side, at most 256 px, keeps the picture - small enough to render quickly. + small enough to render quickly. The picture for a display is drawn with the same + margin and then cut to the display, see `async_render_dashboard`. """ shorter_side: int = min(display["width"], display["height"]) return min(PREVIEW_MARGIN_LIMIT, round(shorter_side * 0.4)) @@ -59,16 +60,22 @@ async def async_render_dashboard( from the Media Source or sent to a device, is turned by the dashboard's rotation so it has the display's own width and height. With `with_margin` the picture also shows a margin around the display, where elements that hang out of it are drawn. + + Either way the dashboard is drawn once, on the picture with its margin, and the + margin is cut off afterwards when it is not wanted. The renderer draws an element + that hangs out of the picture only as far as the picture goes, and a turned text is + turned after it is drawn, so drawing the display on its own would give another + picture than the editor shows. The display receives exactly what the editor shows. """ started = monotonic() data = hass.data[DOMAIN] compiled = await async_compile_dashboard(hass, dashboard, data.widgets) display = dashboard["display"] - margin = preview_margin(display) if with_margin else 0 - elements = compiled.elements - if margin: - explicit = expand_debug_grids(elements, display["width"], display["height"]) - elements = translate_elements(explicit, margin, margin) + margin = preview_margin(display) + explicit = expand_debug_grids( + compiled.elements, display["width"], display["height"] + ) + elements = translate_elements(explicit, margin, margin) accent_color = accent_color_for_palette(display["palette"]) pivots = ( await async_find_pivots( @@ -79,7 +86,7 @@ async def async_render_dashboard( margin=margin, accent_color=accent_color, ) - if margin + if with_margin else {} ) picture = await data.renderer.async_render( @@ -91,6 +98,7 @@ async def async_render_dashboard( accent_color=accent_color, rotation=display["rotation"] if for_device else 0, margin=margin, + show_margin=with_margin, ) pipeline_ms = round((monotonic() - started) * 1000, 2) LOGGER.info( @@ -108,4 +116,5 @@ async def async_render_dashboard( pipeline_ms, len(picture.png), ) - return RenderedDashboard(compiled, picture, pipeline_ms, margin, pivots) + shown_margin = margin if with_margin else 0 + return RenderedDashboard(compiled, picture, pipeline_ms, shown_margin, pivots) diff --git a/custom_components/opendisplay_studio/frontend/opendisplay-studio.js b/custom_components/opendisplay_studio/frontend/opendisplay-studio.js index f75626d..104edd3 100644 --- a/custom_components/opendisplay_studio/frontend/opendisplay-studio.js +++ b/custom_components/opendisplay_studio/frontend/opendisplay-studio.js @@ -138,6 +138,9 @@ var e = (e, t) => `${e} × ${t}`, t = { deviceDetails: "Dashboard size and colors", colors: "Colors", create: "Create dashboard", + previewLabel: "Display preview", + turnLeft: "Turn 90° to the left", + turnRight: "Turn 90° to the right", creating: "Creating…" }, header: { @@ -345,6 +348,14 @@ var e = (e, t) => `${e} × ${t}`, t = { advanced: "Advanced display options", padding: "Outer padding", snapSize: "Snap size", + mediaAsDesigned: "Media Source picture as designed", + mediaAsDesignedHelp: (e) => e === 0 ? "The picture of the Media Source is the same either way: this dashboard is not turned." : [ + "The picture of the Media Source keeps the orientation you see in the designer,", + "which suits a preview or a card. It is not turned for the display, so to show", + `it with opendisplay.upload_image set rotation to ${e}.`, + "Left off, the picture is turned for the display.", + "What the editor sends to a device is always turned for it." + ].join(" "), x: "X", y: "Y", innerPadding: "Inner padding" @@ -528,6 +539,39 @@ var e = (e, t) => `${e} × ${t}`, t = { seven_color: "Seven colors · Spectra 6 and orange", grayscale4: "Grayscale · 4 levels", grayscale16: "Grayscale · 16 levels" + }, + basic: { + modeTitle: "Editor", + basic: "Basic", + basicHint: "A grid of regions, a widget in each.", + advanced: "Advanced", + advancedHint: "Place every element freely.", + badgeBasic: "Basic", + badgeAdvanced: "Advanced", + stepsLabel: "Editor steps", + stepLayout: "Layout", + stepWidgets: "Widgets", + widgetsHeading: "Widgets", + layoutTitle: "Layout", + layoutHeading: "Regions", + gridHeading: "Grid", + columns: "Columns", + rows: "Rows", + gap: "Gap", + regionsHeading: "Regions", + regionsHint: "Click one cell, then another, to join the cells between them into a region. Double-click a region to remove it.", + joinStarted: "Click a second cell to finish the region.", + joinInvalid: "That would cut a region in two.", + cancelJoin: "Cancel", + regionCount: (e) => `${e} ${e === 1 ? "region" : "regions"}`, + cell: (e, t) => `Cell in row ${e} and column ${t}`, + regionSize: (e, t) => `${e}×${t} region`, + region: (e) => `Region ${e}`, + emptyRegion: "Empty region", + emptyRegionHint: "Pick a widget from the list.", + noEmptyRegion: "Every region already holds a widget.", + layoutStepHint: "Draw the regions first, then fill them on the Widgets tab.", + widgetsStepHint: "Drag a widget into a region, or click one to fill the selected region." } }, n = { common: { @@ -664,6 +708,9 @@ var e = (e, t) => `${e} × ${t}`, t = { deviceDetails: "Größe und Farben des Dashboards", colors: "Farben", create: "Dashboard erstellen", + previewLabel: "Vorschau des Displays", + turnLeft: "Um 90° nach links drehen", + turnRight: "Um 90° nach rechts drehen", creating: "Wird erstellt…" }, header: { @@ -854,6 +901,14 @@ var e = (e, t) => `${e} × ${t}`, t = { background: "Hintergrund", advanced: "Erweiterte Display-Optionen", padding: "Äußerer Rand", + mediaAsDesigned: "Bild der Media Source wie im Editor", + mediaAsDesignedHelp: (e) => e === 0 ? "Das Bild der Media Source ist in beiden Fällen gleich: Dieses Dashboard ist nicht gedreht." : [ + "Das Bild der Media Source behält die Ausrichtung aus dem Editor,", + "passend für eine Vorschau oder eine Karte. Es ist nicht für das Display gedreht;", + `um es mit opendisplay.upload_image zu zeigen, setze rotation auf ${e}.`, + "Ausgeschaltet wird das Bild für das Display gedreht.", + "Was der Editor an ein Gerät sendet, ist immer für dieses gedreht." + ].join(" "), snapSize: "Rasterschritt", innerPadding: "Innerer Rand" }, @@ -1046,6 +1101,39 @@ var e = (e, t) => `${e} × ${t}`, t = { seven_color: "Sieben Farben · Spectra 6 und Orange", grayscale4: "Graustufen · 4 Stufen", grayscale16: "Graustufen · 16 Stufen" + }, + basic: { + modeTitle: "Editor", + basic: "Einfach", + basicHint: "Ein Raster aus Bereichen, in jedem ein Widget.", + advanced: "Erweitert", + advancedHint: "Jedes Element frei platzieren.", + badgeBasic: "Einfach", + badgeAdvanced: "Erweitert", + stepsLabel: "Schritte des Editors", + stepLayout: "Layout", + stepWidgets: "Widgets", + widgetsHeading: "Widgets", + layoutTitle: "Layout", + layoutHeading: "Bereiche", + gridHeading: "Raster", + columns: "Spalten", + rows: "Zeilen", + gap: "Abstand", + regionsHeading: "Bereiche", + regionsHint: "Klicke eine Zelle und dann eine zweite, um die Zellen dazwischen zu einem Bereich zu verbinden. Klicke einen Bereich doppelt an, um ihn zu entfernen.", + joinStarted: "Klicke eine zweite Zelle, um den Bereich abzuschließen.", + joinInvalid: "Das würde einen Bereich zerschneiden.", + cancelJoin: "Abbrechen", + regionCount: (e) => `${e} ${e === 1 ? "Bereich" : "Bereiche"}`, + cell: (e, t) => `Zelle in Zeile ${e} und Spalte ${t}`, + regionSize: (e, t) => `Bereich ${e}×${t}`, + region: (e) => `Bereich ${e}`, + emptyRegion: "Leerer Bereich", + emptyRegionHint: "Wähle ein Widget aus der Liste.", + noEmptyRegion: "Jeder Bereich enthält schon ein Widget.", + layoutStepHint: "Zeichne zuerst die Bereiche und fülle sie dann im Tab Widgets.", + widgetsStepHint: "Ziehe ein Widget in einen Bereich oder klicke es an, um den gewählten Bereich zu füllen." } }, r = (e, t, n, r) => { if (e === 1) return t; @@ -1187,6 +1275,9 @@ var e = (e, t) => `${e} × ${t}`, t = { deviceDetails: "Rozmiar i kolory dashboardu", colors: "Kolory", create: "Utwórz dashboard", + previewLabel: "Podgląd wyświetlacza", + turnLeft: "Obróć o 90° w lewo", + turnRight: "Obróć o 90° w prawo", creating: "Tworzenie…" }, header: { @@ -1377,6 +1468,14 @@ var e = (e, t) => `${e} × ${t}`, t = { background: "Tło", advanced: "Zaawansowane ustawienia wyświetlacza", padding: "Margines zewnętrzny", + mediaAsDesigned: "Obraz Media Source tak jak w edytorze", + mediaAsDesignedHelp: (e) => e === 0 ? "Obraz Media Source jest taki sam w obu przypadkach: ten dashboard nie jest obrócony." : [ + "Obraz Media Source zachowuje orientację z edytora, co dobrze nadaje się", + "do podglądu albo karty. Nie jest obrócony dla wyświetlacza, więc aby pokazać go", + `przez opendisplay.upload_image, ustaw rotation na ${e}.`, + "Gdy wyłączone, obraz jest obrócony dla wyświetlacza.", + "To, co edytor wysyła na urządzenie, jest zawsze obrócone dla niego." + ].join(" "), snapSize: "Krok przyciągania", innerPadding: "Margines wewnętrzny" }, @@ -1569,6 +1668,39 @@ var e = (e, t) => `${e} × ${t}`, t = { seven_color: "Siedem kolorów · Spectra 6 i pomarańczowy", grayscale4: "Skala szarości · 4 poziomy", grayscale16: "Skala szarości · 16 poziomów" + }, + basic: { + modeTitle: "Edytor", + basic: "Podstawowy", + basicHint: "Siatka regionów, w każdym widżet.", + advanced: "Zaawansowany", + advancedHint: "Każdy element ustawiasz swobodnie.", + badgeBasic: "Podstawowy", + badgeAdvanced: "Zaawansowany", + stepsLabel: "Kroki edytora", + stepLayout: "Układ", + stepWidgets: "Widżety", + widgetsHeading: "Widżety", + layoutTitle: "Układ", + layoutHeading: "Regiony", + gridHeading: "Siatka", + columns: "Kolumny", + rows: "Wiersze", + gap: "Odstęp", + regionsHeading: "Regiony", + regionsHint: "Kliknij jedną komórkę, potem drugą, aby połączyć komórki między nimi w region. Kliknij region dwukrotnie, aby go usunąć.", + joinStarted: "Kliknij drugą komórkę, aby zakończyć region.", + joinInvalid: "To przecięłoby region na pół.", + cancelJoin: "Anuluj", + regionCount: (e) => `${e} ${r(e, "region", "regiony", "regionów")}`, + cell: (e, t) => `Komórka w wierszu ${e} i kolumnie ${t}`, + regionSize: (e, t) => `region ${e}×${t}`, + region: (e) => `Region ${e}`, + emptyRegion: "Pusty region", + emptyRegionHint: "Wybierz widżet z listy.", + noEmptyRegion: "Każdy region ma już widżet.", + layoutStepHint: "Najpierw narysuj regiony, potem wypełnij je na karcie Widżety.", + widgetsStepHint: "Przeciągnij widżet do regionu albo kliknij go, aby wypełnić zaznaczony region." } }, a = "en", o = { de: n, @@ -7059,16 +7191,16 @@ var jc = class extends HTMLElement { jc.elementStyles = [], jc.shadowRootOptions = { mode: "open" }, jc[Dc("elementProperties")] = /* @__PURE__ */ new Map(), jc[Dc("finalized")] = /* @__PURE__ */ new Map(), Ec?.({ ReactiveElement: jc }), (Cc.reactiveElementVersions ??= []).push("2.1.2"); //#endregion //#region node_modules/lit-html/lit-html.js -var Mc = globalThis, Nc = (e) => e, Pc = Mc.trustedTypes, Fc = Pc ? Pc.createPolicy("lit-html", { createHTML: (e) => e }) : void 0, Ic = "$lit$", Lc = `lit$${Math.random().toFixed(9).slice(2)}$`, Rc = "?" + Lc, zc = `<${Rc}>`, Bc = document, Vc = () => Bc.createComment(""), Hc = (e) => e === null || typeof e != "object" && typeof e != "function", Uc = Array.isArray, Wc = (e) => Uc(e) || typeof e?.[Symbol.iterator] == "function", Gc = "[ \n\f\r]", Kc = /<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g, qc = /-->/g, Jc = />/g, Yc = RegExp(`>|${Gc}(?:([^\\s"'>=/]+)(${Gc}*=${Gc}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`, "g"), Xc = /'/g, Zc = /"/g, Qc = /^(?:script|style|textarea|title)$/i, F = ((e) => (t, ...n) => ({ +var Mc = globalThis, Nc = (e) => e, Pc = Mc.trustedTypes, Fc = Pc ? Pc.createPolicy("lit-html", { createHTML: (e) => e }) : void 0, Ic = "$lit$", Lc = `lit$${Math.random().toFixed(9).slice(2)}$`, Rc = "?" + Lc, zc = `<${Rc}>`, Bc = document, Vc = () => Bc.createComment(""), Hc = (e) => e === null || typeof e != "object" && typeof e != "function", Uc = Array.isArray, Wc = (e) => Uc(e) || typeof e?.[Symbol.iterator] == "function", Gc = "[ \n\f\r]", Kc = /<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g, qc = /-->/g, Jc = />/g, Yc = RegExp(`>|${Gc}(?:([^\\s"'>=/]+)(${Gc}*=${Gc}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`, "g"), Xc = /'/g, Zc = /"/g, Qc = /^(?:script|style|textarea|title)$/i, $c = (e) => (t, ...n) => ({ _$litType$: e, strings: t, values: n -}))(1), $c = Symbol.for("lit-noChange"), I = Symbol.for("lit-nothing"), el = /* @__PURE__ */ new WeakMap(), tl = Bc.createTreeWalker(Bc, 129); -function nl(e, t) { +}), F = $c(1), el = $c(2), tl = Symbol.for("lit-noChange"), I = Symbol.for("lit-nothing"), nl = /* @__PURE__ */ new WeakMap(), rl = Bc.createTreeWalker(Bc, 129); +function il(e, t) { if (!Uc(e) || !e.hasOwnProperty("raw")) throw Error("invalid template strings array"); return Fc === void 0 ? t : Fc.createHTML(t); } -var rl = (e, t) => { +var al = (e, t) => { let n = e.length - 1, r = [], i, a = t === 2 ? "" : t === 3 ? "" : "", o = Kc; for (let t = 0; t < n; t++) { let n = e[t], s, c, l = -1, u = 0; @@ -7076,17 +7208,17 @@ var rl = (e, t) => { let d = o === Yc && e[t + 1].startsWith("/>") ? " " : ""; a += o === Kc ? n + zc : l >= 0 ? (r.push(s), n.slice(0, l) + Ic + n.slice(l) + Lc + d) : n + Lc + (l === -2 ? t : d); } - return [nl(e, a + (e[n] || "") + (t === 2 ? "" : t === 3 ? "" : "")), r]; -}, il = class e { + return [il(e, a + (e[n] || "") + (t === 2 ? "" : t === 3 ? "" : "")), r]; +}, ol = class e { constructor({ strings: t, _$litType$: n }, r) { let i; this.parts = []; - let a = 0, o = 0, s = t.length - 1, c = this.parts, [l, u] = rl(t, n); - if (this.el = e.createElement(l, r), tl.currentNode = this.el.content, n === 2 || n === 3) { + let a = 0, o = 0, s = t.length - 1, c = this.parts, [l, u] = al(t, n); + if (this.el = e.createElement(l, r), rl.currentNode = this.el.content, n === 2 || n === 3) { let e = this.el.content.firstChild; e.replaceWith(...e.childNodes); } - for (; (i = tl.nextNode()) !== null && c.length < s;) { + for (; (i = rl.nextNode()) !== null && c.length < s;) { if (i.nodeType === 1) { if (i.hasAttributes()) for (let e of i.getAttributeNames()) if (e.endsWith(Ic)) { let t = u[o++], n = i.getAttribute(e).split(Lc), r = /([.?@])?(.*)/.exec(t); @@ -7095,7 +7227,7 @@ var rl = (e, t) => { index: a, name: r[2], strings: n, - ctor: r[1] === "." ? ll : r[1] === "?" ? ul : r[1] === "@" ? dl : cl + ctor: r[1] === "." ? dl : r[1] === "?" ? fl : r[1] === "@" ? pl : ul }), i.removeAttribute(e); } else e.startsWith(Lc) && (c.push({ type: 6, @@ -7105,7 +7237,7 @@ var rl = (e, t) => { let e = i.textContent.split(Lc), t = e.length - 1; if (t > 0) { i.textContent = Pc ? Pc.emptyScript : ""; - for (let n = 0; n < t; n++) i.append(e[n], Vc()), tl.nextNode(), c.push({ + for (let n = 0; n < t; n++) i.append(e[n], Vc()), rl.nextNode(), c.push({ type: 2, index: ++a }); @@ -7133,12 +7265,12 @@ var rl = (e, t) => { return n.innerHTML = e, n; } }; -function al(e, t, n = e, r) { - if (t === $c) return t; +function sl(e, t, n = e, r) { + if (t === tl) return t; let i = r === void 0 ? n._$Cl : n._$Co?.[r], a = Hc(t) ? void 0 : t._$litDirective$; - return i?.constructor !== a && (i?._$AO?.(!1), a === void 0 ? i = void 0 : (i = new a(e), i._$AT(e, n, r)), r === void 0 ? n._$Cl = i : (n._$Co ??= [])[r] = i), i !== void 0 && (t = al(e, i._$AS(e, t.values), i, r)), t; + return i?.constructor !== a && (i?._$AO?.(!1), a === void 0 ? i = void 0 : (i = new a(e), i._$AT(e, n, r)), r === void 0 ? n._$Cl = i : (n._$Co ??= [])[r] = i), i !== void 0 && (t = sl(e, i._$AS(e, t.values), i, r)), t; } -var ol = class { +var cl = class { constructor(e, t) { this._$AV = [], this._$AN = void 0, this._$AD = e, this._$AM = t; } @@ -7150,22 +7282,22 @@ var ol = class { } u(e) { let { el: { content: t }, parts: n } = this._$AD, r = (e?.creationScope ?? Bc).importNode(t, !0); - tl.currentNode = r; - let i = tl.nextNode(), a = 0, o = 0, s = n[0]; + rl.currentNode = r; + let i = rl.nextNode(), a = 0, o = 0, s = n[0]; for (; s !== void 0;) { if (a === s.index) { let t; - s.type === 2 ? t = new sl(i, i.nextSibling, this, e) : s.type === 1 ? t = new s.ctor(i, s.name, s.strings, this, e) : s.type === 6 && (t = new fl(i, this, e)), this._$AV.push(t), s = n[++o]; + s.type === 2 ? t = new ll(i, i.nextSibling, this, e) : s.type === 1 ? t = new s.ctor(i, s.name, s.strings, this, e) : s.type === 6 && (t = new ml(i, this, e)), this._$AV.push(t), s = n[++o]; } - a !== s?.index && (i = tl.nextNode(), a++); + a !== s?.index && (i = rl.nextNode(), a++); } - return tl.currentNode = Bc, r; + return rl.currentNode = Bc, r; } p(e) { let t = 0; for (let n of this._$AV) n !== void 0 && (n.strings === void 0 ? n._$AI(e[t]) : (n._$AI(e, n, t), t += n.strings.length - 2)), t++; } -}, sl = class e { +}, ll = class e { get _$AU() { return this._$AM?._$AU ?? this._$Cv; } @@ -7183,7 +7315,7 @@ var ol = class { return this._$AB; } _$AI(e, t = this) { - e = al(this, e, t), Hc(e) ? e === I || e == null || e === "" ? (this._$AH !== I && this._$AR(), this._$AH = I) : e !== this._$AH && e !== $c && this._(e) : e._$litType$ === void 0 ? e.nodeType === void 0 ? Wc(e) ? this.k(e) : this._(e) : this.T(e) : this.$(e); + e = sl(this, e, t), Hc(e) ? e === I || e == null || e === "" ? (this._$AH !== I && this._$AR(), this._$AH = I) : e !== this._$AH && e !== tl && this._(e) : e._$litType$ === void 0 ? e.nodeType === void 0 ? Wc(e) ? this.k(e) : this._(e) : this.T(e) : this.$(e); } O(e) { return this._$AA.parentNode.insertBefore(e, this._$AB); @@ -7195,16 +7327,16 @@ var ol = class { this._$AH !== I && Hc(this._$AH) ? this._$AA.nextSibling.data = e : this.T(Bc.createTextNode(e)), this._$AH = e; } $(e) { - let { values: t, _$litType$: n } = e, r = typeof n == "number" ? this._$AC(e) : (n.el === void 0 && (n.el = il.createElement(nl(n.h, n.h[0]), this.options)), n); + let { values: t, _$litType$: n } = e, r = typeof n == "number" ? this._$AC(e) : (n.el === void 0 && (n.el = ol.createElement(il(n.h, n.h[0]), this.options)), n); if (this._$AH?._$AD === r) this._$AH.p(t); else { - let e = new ol(r, this), n = e.u(this.options); + let e = new cl(r, this), n = e.u(this.options); e.p(t), this.T(n), this._$AH = e; } } _$AC(e) { - let t = el.get(e.strings); - return t === void 0 && el.set(e.strings, t = new il(e)), t; + let t = nl.get(e.strings); + return t === void 0 && nl.set(e.strings, t = new ol(e)), t; } k(t) { Uc(this._$AH) || (this._$AH = [], this._$AR()); @@ -7221,7 +7353,7 @@ var ol = class { setConnected(e) { this._$AM === void 0 && (this._$Cv = e, this._$AP?.(e)); } -}, cl = class { +}, ul = class { get tagName() { return this.element.tagName; } @@ -7233,43 +7365,43 @@ var ol = class { } _$AI(e, t = this, n, r) { let i = this.strings, a = !1; - if (i === void 0) e = al(this, e, t, 0), a = !Hc(e) || e !== this._$AH && e !== $c, a && (this._$AH = e); + if (i === void 0) e = sl(this, e, t, 0), a = !Hc(e) || e !== this._$AH && e !== tl, a && (this._$AH = e); else { let r = e, o, s; - for (e = i[0], o = 0; o < i.length - 1; o++) s = al(this, r[n + o], t, o), s === $c && (s = this._$AH[o]), a ||= !Hc(s) || s !== this._$AH[o], s === I ? e = I : e !== I && (e += (s ?? "") + i[o + 1]), this._$AH[o] = s; + for (e = i[0], o = 0; o < i.length - 1; o++) s = sl(this, r[n + o], t, o), s === tl && (s = this._$AH[o]), a ||= !Hc(s) || s !== this._$AH[o], s === I ? e = I : e !== I && (e += (s ?? "") + i[o + 1]), this._$AH[o] = s; } a && !r && this.j(e); } j(e) { e === I ? this.element.removeAttribute(this.name) : this.element.setAttribute(this.name, e ?? ""); } -}, ll = class extends cl { +}, dl = class extends ul { constructor() { super(...arguments), this.type = 3; } j(e) { this.element[this.name] = e === I ? void 0 : e; } -}, ul = class extends cl { +}, fl = class extends ul { constructor() { super(...arguments), this.type = 4; } j(e) { this.element.toggleAttribute(this.name, !!e && e !== I); } -}, dl = class extends cl { +}, pl = class extends ul { constructor(e, t, n, r, i) { super(e, t, n, r, i), this.type = 5; } _$AI(e, t = this) { - if ((e = al(this, e, t, 0) ?? I) === $c) return; + if ((e = sl(this, e, t, 0) ?? I) === tl) return; let n = this._$AH, r = e === I && n !== I || e.capture !== n.capture || e.once !== n.once || e.passive !== n.passive, i = e !== I && (n === I || r); r && this.element.removeEventListener(this.name, this, n), i && this.element.addEventListener(this.name, this, e), this._$AH = e; } handleEvent(e) { typeof this._$AH == "function" ? this._$AH.call(this.options?.host ?? this.element, e) : this._$AH.handleEvent(e); } -}, fl = class { +}, ml = class { constructor(e, t, n) { this.element = e, this.type = 6, this._$AN = void 0, this._$AM = t, this.options = n; } @@ -7277,18 +7409,18 @@ var ol = class { return this._$AM._$AU; } _$AI(e) { - al(this, e); + sl(this, e); } -}, pl = Mc.litHtmlPolyfillSupport; -pl?.(il, sl), (Mc.litHtmlVersions ??= []).push("3.3.3"); -var ml = (e, t, n) => { +}, hl = Mc.litHtmlPolyfillSupport; +hl?.(ol, ll), (Mc.litHtmlVersions ??= []).push("3.3.3"); +var gl = (e, t, n) => { let r = n?.renderBefore ?? t, i = r._$litPart$; if (i === void 0) { let e = n?.renderBefore ?? null; - r._$litPart$ = i = new sl(t.insertBefore(Vc(), e), e, void 0, n ?? {}); + r._$litPart$ = i = new ll(t.insertBefore(Vc(), e), e, void 0, n ?? {}); } return i._$AI(e), i; -}, hl = globalThis, L = class extends jc { +}, _l = globalThis, L = class extends jc { constructor() { super(...arguments), this.renderOptions = { host: this }, this._$Do = void 0; } @@ -7298,7 +7430,7 @@ var ml = (e, t, n) => { } update(e) { let t = this.render(); - this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(e), this._$Do = ml(t, this.renderRoot, this.renderOptions); + this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(e), this._$Do = gl(t, this.renderRoot, this.renderOptions); } connectedCallback() { super.connectedCallback(), this._$Do?.setConnected(!0); @@ -7307,25 +7439,25 @@ var ml = (e, t, n) => { super.disconnectedCallback(), this._$Do?.setConnected(!1); } render() { - return $c; + return tl; } }; -L._$litElement$ = !0, L.finalized = !0, hl.litElementHydrateSupport?.({ LitElement: L }); -var gl = hl.litElementPolyfillSupport; -gl?.({ LitElement: L }), (hl.litElementVersions ??= []).push("4.2.2"); +L._$litElement$ = !0, L.finalized = !0, _l.litElementHydrateSupport?.({ LitElement: L }); +var vl = _l.litElementPolyfillSupport; +vl?.({ LitElement: L }), (_l.litElementVersions ??= []).push("4.2.2"); //#endregion //#region node_modules/@lit/reactive-element/decorators/custom-element.js var R = (e) => (t, n) => { n === void 0 ? customElements.define(e, t) : n.addInitializer(() => { customElements.define(e, t); }); -}, _l = { +}, yl = { attribute: !0, type: String, converter: Oc, reflect: !1, hasChanged: kc -}, vl = (e = _l, t, n) => { +}, bl = (e = yl, t, n) => { let { kind: r, metadata: i } = n, a = globalThis.litPropertyMetadata.get(i); if (a === void 0 && globalThis.litPropertyMetadata.set(i, a = /* @__PURE__ */ new Map()), r === "setter" && ((e = Object.create(e)).wrapped = !0), a.set(n.name, e), r === "accessor") { let { name: r } = n; @@ -7349,7 +7481,7 @@ var R = (e) => (t, n) => { throw Error("Unsupported decorator location: " + r); }; function z(e) { - return (t, n) => typeof n == "object" ? vl(e, t, n) : ((e, t, n) => { + return (t, n) => typeof n == "object" ? bl(e, t, n) : ((e, t, n) => { let r = t.hasOwnProperty(n); return t.constructor.createProperty(n, e), r ? Object.getOwnPropertyDescriptor(t, n) : void 0; })(e, t, n); @@ -7365,10 +7497,10 @@ function B(e) { } //#endregion //#region node_modules/@lit/reactive-element/decorators/base.js -var yl = (e, t, n) => (n.configurable = !0, n.enumerable = !0, Reflect.decorate && typeof t != "object" && Object.defineProperty(e, t, n), n); +var xl = (e, t, n) => (n.configurable = !0, n.enumerable = !0, Reflect.decorate && typeof t != "object" && Object.defineProperty(e, t, n), n); //#endregion //#region node_modules/@lit/reactive-element/decorators/query.js -function bl(e, t) { +function Sl(e, t) { return (n, r, i) => { let a = (t) => t.renderRoot?.querySelector(e) ?? null; if (t) { @@ -7383,29 +7515,29 @@ function bl(e, t) { } }; })(); - return yl(n, r, { get() { + return xl(n, r, { get() { let n = e.call(this); return n === void 0 && (n = a(this), (n !== null || this.hasUpdated) && t.call(this, n)), n; } }); } - return yl(n, r, { get() { + return xl(n, r, { get() { return a(this); } }); }; } //#endregion //#region node_modules/lit-html/directive.js -var xl = { +var Cl = { ATTRIBUTE: 1, CHILD: 2, PROPERTY: 3, BOOLEAN_ATTRIBUTE: 4, EVENT: 5, ELEMENT: 6 -}, Sl = (e) => (...t) => ({ +}, wl = (e) => (...t) => ({ _$litDirective$: e, values: t -}), Cl = class { +}), Tl = class { constructor(e) {} get _$AU() { return this._$AM._$AU; @@ -7419,9 +7551,9 @@ var xl = { update(e, t) { return this.render(...t); } -}, wl = "important", Tl = " !" + wl, V = Sl(class extends Cl { +}, El = "important", Dl = " !" + El, V = wl(class extends Tl { constructor(e) { - if (super(e), e.type !== xl.ATTRIBUTE || e.name !== "style" || e.strings?.length > 2) throw Error("The `styleMap` directive must be used in the `style` attribute and must be the only part in the attribute."); + if (super(e), e.type !== Cl.ATTRIBUTE || e.name !== "style" || e.strings?.length > 2) throw Error("The `styleMap` directive must be used in the `style` attribute and must be the only part in the attribute."); } render(e) { return Object.keys(e).reduce((t, n) => { @@ -7437,13 +7569,13 @@ var xl = { let r = t[e]; if (r != null) { this.ft.add(e); - let t = typeof r == "string" && r.endsWith(Tl); - e.includes("-") || t ? n.setProperty(e, t ? r.slice(0, -11) : r, t ? wl : "") : n[e] = r; + let t = typeof r == "string" && r.endsWith(Dl); + e.includes("-") || t ? n.setProperty(e, t ? r.slice(0, -11) : r, t ? El : "") : n[e] = r; } } - return $c; + return tl; } -}), El = (e, t, n, r, i, a) => { +}), Ol = (e, t, n, r, i, a) => { let o = e.primitive, s = a?.fields.find((e) => e.key === t); if (s?.shape === "coordinate") { let i = x(e), a = { ...o }, c = Number(Reflect.get(a, t)), l = s.axis === "x", u = (l ? i.x : i.y) - c, [d, f] = gt(l ? r.x : r.y, l ? r.width : r.height, l ? i.width : i.height); @@ -7454,9 +7586,9 @@ var xl = { let e = et(s.min, i, n), r = et(s.max, i, n); Object.assign(o, { [t]: _(n, e, r) }); } -}, Dl = (e, t, n, r, i) => { +}, kl = (e, t, n, r, i) => { t === "x" && (e.x = _(n, ...gt(r.x, r.width, e.width))), t === "y" && (e.y = _(n, ...gt(r.y, r.height, e.height))), t === "width" && (e.width = _(n, 1, Qe(i.width))), t === "height" && (e.height = _(n, 1, Qe(i.height))); -}, Ol = (e, t, n, r, i) => { +}, Al = (e, t, n, r, i) => { if (t === "x") { let t = e.x_end - e.x_start; e.x_start = _(n, ...gt(r.x, r.width, t + 1)), e.x_end = e.x_start + t; @@ -7466,10 +7598,10 @@ var xl = { e.y_start = _(n, ...gt(r.y, r.height, t + 1)), e.y_end = e.y_start + t; } t === "width" && (e.x_end = _(e.x_start + Math.max(1, n) - 1, e.x_start + 1, e.x_start + Qe(i.width) - 1)), t === "height" && (e.y_end = _(e.y_start + Math.max(1, n) - 1, e.y_start + 1, e.y_start + Qe(i.height) - 1)); -}, kl = (e, t, n, r, i) => { +}, jl = (e, t, n, r, i) => { let a = pt(r); if (e.kind === "widget") { - t === "padding" && (e.layout.padding = _(n, 0, 128)), Dl(e.frame, t, n, a, r.display); + t === "padding" && (e.layout.padding = _(n, 0, 128)), kl(e.frame, t, n, a, r.display); return; } if (e.kind === "container") { @@ -7477,47 +7609,47 @@ var xl = { width: e.width, height: e.height }; - Dl(e, t, n, a, r.display), e.grouped && ft(e, e.width / Math.max(1, o.width), e.height / Math.max(1, o.height), i, r.display); + kl(e, t, n, a, r.display), e.grouped && ft(e, e.width / Math.max(1, o.width), e.height / Math.max(1, o.height), i, r.display); return; } let o = e.primitive; - ue(o) ? Ol(o, t, n, a, r.display) : El(e, t, n, a, r.display, Bo(e, i)); -}, Al = (e, t, n, r, i) => { + ue(o) ? Al(o, t, n, a, r.display) : Ol(e, t, n, a, r.display, Bo(e, i)); +}, Ml = (e, t, n, r, i) => { let a = w(e.items, t); if (!a || a.item.locked) return; let { item: o, offset: s } = a; - S(o, s.x, s.y), kl(o, n, jl(n, r, s), e, i), S(o, -s.x, -s.y); -}, jl = (e, t, n) => e === "x" ? t + n.x : e === "y" ? t + n.y : t, Ml = (e) => e.items.forEach((t) => ht(t, e)), Nl = (e, t, n) => { - t === "padding" && (e.display.padding = _(n, 0, Math.floor((Math.min(e.display.width, e.display.height) - 1) / 2))), t === "snapSize" && (e.display.snapSize = _(n, 1, 256)), Ml(e); -}, Pl = (e, t) => { + S(o, s.x, s.y), jl(o, n, Nl(n, r, s), e, i), S(o, -s.x, -s.y); +}, Nl = (e, t, n) => e === "x" ? t + n.x : e === "y" ? t + n.y : t, Pl = (e) => e.items.forEach((t) => ht(t, e)), Fl = (e, t, n) => { + t === "padding" && (e.display.padding = _(n, 0, Math.floor((Math.min(e.display.width, e.display.height) - 1) / 2))), t === "snapSize" && (e.display.snapSize = _(n, 1, 256)), Pl(e); +}, Il = (e, t) => { xo[e.display.palette].includes(t) && (e.display.background = t); -}, Fl = (e, t, n) => { +}, Ll = (e, t, n) => { let r = T(e.items, t); r && (r[n] = !r[n]); -}, Il = (e, t) => { +}, Rl = (e, t) => { Pt(e, t); -}, Ll = (e, t, n, r) => { +}, zl = (e, t, n, r) => { if (r === "inside") { Nt(e, t, n || void 0, S); return; } Rt(e, t, n, r, S); -}, Rl = (e, t, n) => { +}, Bl = (e, t, n) => { let r = T(e.items, t), i = n.trim(); r && i && (r.name = i.slice(0, 100)); -}, zl = (e, t, n) => { +}, Vl = (e, t, n) => { let r = T(e.items, t); r?.kind === "widget" && (r.widget.options = { ...r.widget.options, ...n }); -}, Bl = (e, t, n, r) => { +}, Hl = (e, t, n, r) => { let i = T(e.items, t); i?.kind === "widget" && (i.widget.sources = { ...i.widget.sources, [n]: r }); -}, Vl = (e, t, n, r, i) => { +}, Ul = (e, t, n, r, i) => { let a = w(e.items, t); if (a?.item.kind !== "primitive" || a.item.locked) return; let o = a.item, s = ["x", "y"].some((e) => o.expressions?.[e]); @@ -7529,7 +7661,7 @@ var xl = { ...o.primitive, [n]: r }; -}, Hl = (e, t, n) => { +}, Wl = (e, t, n) => { let r = T(e.items, t); if (!(r?.kind !== "primitive" || r.locked) && (r.primitive = { ...r.primitive, @@ -7538,7 +7670,7 @@ var xl = { for (let e of Object.keys(n)) delete r.expressions[e]; Object.keys(r.expressions).length === 0 && delete r.expressions; } -}, Ul = (e, t, n, r) => { +}, Gl = (e, t, n, r) => { let i = T(e.items, t); if (i?.kind !== "primitive") return; let a = ts(n, Bo(i, r)); @@ -7546,7 +7678,7 @@ var xl = { ...i.primitive, ...a }; -}, Wl = (e, t, n, r) => { +}, Kl = (e, t, n, r) => { let i = pt(r), a = Math.min(e.layout.defaultSize?.width ?? 240, i.width), o = Math.min(e.layout.defaultSize?.height ?? 144, i.height); return { id: g(), @@ -7568,7 +7700,7 @@ var xl = { }, layout: { padding: 0 } }; -}, Gl = (e, t, n, r, i) => { +}, ql = (e, t, n, r, i) => { let { width: a, height: o } = i.display, s = at(e.find((e) => e.type === t.trim()), { x: Math.round(a / 2), y: Math.round(o / 2), @@ -7585,21 +7717,21 @@ var xl = { primitive: s }, l = x(c); return S(c, Math.round(n - (l.x + l.width / 2)), Math.round(r - (l.y + l.height / 2))), ht(c, i), c; -}, Kl = (e, t) => { +}, Jl = (e, t) => { let n = pt(e), r = E(e.items).length * Math.max(e.display.snapSize, 5) * 3 % Math.max(1, Math.min(n.width, n.height) / 3); return { x: mt(n.x + Math.min(24 + r, Math.max(0, n.width - 1)), e, t), y: mt(n.y + Math.min(24 + r, Math.max(0, n.height - 1)), e, t) }; -}, ql = (e, t) => { +}, Yl = (e, t) => { if (t === "visible") return !e.hidden; if (e.kind === "primitive") return Object.entries(e.primitive).find(([e]) => e === t)?.[1]; -}, Jl = (e, t, n, r) => { +}, Xl = (e, t, n, r) => { let i = T(e.items, t); if (!i || i.locked) return; let a = { ...i.expressions }; - r === null ? delete a[n] : a[n] = r ?? kn(ql(i, n)), Object.keys(a).length === 0 ? delete i.expressions : i.expressions = a; -}, Yl = { + r === null ? delete a[n] : a[n] = r ?? kn(Yl(i, n)), Object.keys(a).length === 0 ? delete i.expressions : i.expressions = a; +}, Zl = { align: { selection: 2, parent: 1 @@ -7608,7 +7740,7 @@ var xl = { selection: 3, parent: 2 } -}, Xl = (e, t) => t === 1 ? "parent" : e, Zl = { +}, Ql = (e, t) => t === 1 ? "parent" : e, $l = { left: { axis: "x", fraction: 0 @@ -7633,11 +7765,11 @@ var xl = { axis: "y", fraction: 1 } -}, Ql = (e, t) => t === "x" ? e.x : e.y, $l = (e, t) => t === "x" ? e.width : e.height, eu = (e, t) => Ql(e, t) + $l(e, t), tu = (e, t, n) => ({ +}, eu = (e, t) => t === "x" ? e.x : e.y, tu = (e, t) => t === "x" ? e.width : e.height, nu = (e, t) => eu(e, t) + tu(e, t), ru = (e, t, n) => ({ id: e, dx: t === "x" ? n : 0, dy: t === "y" ? n : 0 -}), nu = (e, t) => { +}), iu = (e, t) => { let { parent: n, offset: r } = t; return n ? { x: r.x, @@ -7645,48 +7777,48 @@ var xl = { width: n.width, height: n.height } : pt(e); -}, ru = (e, t, n) => { +}, au = (e, t, n) => { let r = new Set(t); return [...r].flatMap((t) => { let i = w(e.items, t); - return !i || i.item.locked || !n.movable(i.item) || iu(e, t, r) ? [] : [{ + return !i || i.item.locked || !n.movable(i.item) || ou(e, t, r) ? [] : [{ id: t, box: At(x(i.item, n.measure(i.item)), i.offset), - area: nu(e, i) + area: iu(e, i) }]; }); -}, iu = (e, t, n) => { +}, ou = (e, t, n) => { let r = w(e.items, t)?.parent; for (; r;) { if (n.has(r.id)) return !0; r = w(e.items, r.id)?.parent; } return !1; -}, au = (e) => e.filter((e) => e.dx !== 0 || e.dy !== 0), ou = (e, t, n, r) => { - let i = ru(e, t, r), a = Xl(r.target, i.length); - if (i.length < Yl.align[a]) return []; - let { axis: o, fraction: s } = Zl[n], c = Qt(i.map((e) => e.box)); - return au(i.map((e) => { - let t = a === "parent" ? e.area : c, n = $l(t, o) - $l(e.box, o), r = Ql(t, o) + n * s; - return tu(e.id, o, Math.round(r - Ql(e.box, o))); +}, su = (e) => e.filter((e) => e.dx !== 0 || e.dy !== 0), cu = (e, t, n, r) => { + let i = au(e, t, r), a = Ql(r.target, i.length); + if (i.length < Zl.align[a]) return []; + let { axis: o, fraction: s } = $l[n], c = Qt(i.map((e) => e.box)); + return su(i.map((e) => { + let t = a === "parent" ? e.area : c, n = tu(t, o) - tu(e.box, o), r = eu(t, o) + n * s; + return ru(e.id, o, Math.round(r - eu(e.box, o))); })); -}, su = (e, t) => { +}, lu = (e, t) => { let [n, ...r] = t.map((e) => e.area); return r.every((e) => e.x === n.x && e.y === n.y && e.width === n.width && e.height === n.height) ? n : pt(e); -}, cu = (e, t, n, r) => { - let i = ru(e, t, r); - if (i.length < Yl.distribute[r.target]) return []; - let a = n === "horizontal" ? "x" : "y", o = [...i].sort((e, t) => Ql(e.box, a) - Ql(t.box, a) || eu(e.box, a) - eu(t.box, a)), s = r.target === "parent" ? su(e, i) : Qt(i.map((e) => e.box)), c = o.reduce((e, t) => e + $l(t.box, a), 0), l = ($l(s, a) - c) / (o.length - 1), u = 0; - return au(o.map((e, t) => { - let n = Math.round(Ql(s, a) + u + l * t); - return u += $l(e.box, a), tu(e.id, a, n - Ql(e.box, a)); +}, uu = (e, t, n, r) => { + let i = au(e, t, r); + if (i.length < Zl.distribute[r.target]) return []; + let a = n === "horizontal" ? "x" : "y", o = [...i].sort((e, t) => eu(e.box, a) - eu(t.box, a) || nu(e.box, a) - nu(t.box, a)), s = r.target === "parent" ? lu(e, i) : Qt(i.map((e) => e.box)), c = o.reduce((e, t) => e + tu(t.box, a), 0), l = (tu(s, a) - c) / (o.length - 1), u = 0; + return su(o.map((e, t) => { + let n = Math.round(eu(s, a) + u + l * t); + return u += tu(e.box, a), ru(e.id, a, n - eu(e.box, a)); })); -}, lu = (e, t) => { +}, du = (e, t) => { for (let n of t) { let t = w(e.items, n.id); t && S(t.item, n.dx, n.dy); } -}, uu = (e) => ({ +}, fu = (e) => ({ canUndo: !1, canRedo: !1, item: e, @@ -7694,13 +7826,13 @@ var xl = { canGroup: e.kind === "container" && !e.grouped, canUngroup: e.kind === "container" && e.grouped, canEnter: e.kind === "container" && e.grouped -}), du = (e) => e.targets ?? (e.item ? [e.item] : []), fu = (e) => du(e).length > 0, pu = (e) => du(e).filter((e) => !e.locked).length, mu = (e) => { - let t = pu(e), n = Xl(e.alignTo ?? "selection", t); - return t >= Yl.align[n]; -}, hu = (e) => pu(e) >= Yl.distribute[e.alignTo ?? "selection"], H = (e, t) => { - let n = du(e).map((e) => e.id); +}), pu = (e) => e.targets ?? (e.item ? [e.item] : []), mu = (e) => pu(e).length > 0, hu = (e) => pu(e).filter((e) => !e.locked).length, gu = (e) => { + let t = hu(e), n = Ql(e.alignTo ?? "selection", t); + return t >= Zl.align[n]; +}, _u = (e) => hu(e) >= Zl.distribute[e.alignTo ?? "selection"], H = (e, t) => { + let n = pu(e).map((e) => e.id); n.length > 0 && t(n); -}, gu = { +}, vu = { left: { key: "ArrowLeft", icon: "mdi:arrow-left" @@ -7717,45 +7849,45 @@ var xl = { key: "ArrowDown", icon: "mdi:arrow-down" } -}, _u = () => Object.keys(gu).flatMap((e) => [!1, !0].map((n) => ({ +}, yu = () => Object.keys(vu).flatMap((e) => [!1, !0].map((n) => ({ id: n ? `nudge-${e}-snap` : `nudge-${e}`, group: "arrange", label: () => t.commands.nudge[e], - icon: () => gu[e].icon, + icon: () => vu[e].icon, shortcuts: [{ - key: gu[e].key, + key: vu[e].key, shift: n }], - isEnabled: fu, + isEnabled: mu, isRelevant: () => !1, run: (t, r) => H(t, (t) => r.nudge(t, e, n)) -}))), vu = { +}))), bu = { left: "mdi:align-horizontal-left", center: "mdi:align-horizontal-center", right: "mdi:align-horizontal-right", top: "mdi:align-vertical-top", middle: "mdi:align-vertical-center", bottom: "mdi:align-vertical-bottom" -}, yu = { +}, xu = { horizontal: "mdi:arrow-expand-horizontal", vertical: "mdi:arrow-expand-vertical" -}, bu = () => Object.keys(vu).map((e) => ({ +}, Su = () => Object.keys(bu).map((e) => ({ id: `align-${e}`, group: "arrange", label: () => t.commands.align[e], - icon: () => vu[e], + icon: () => bu[e], shortcuts: [], - isEnabled: mu, + isEnabled: gu, run: (t, n) => H(t, (t) => n.align(t, e)) -})), xu = () => Object.keys(yu).map((e) => ({ +})), Cu = () => Object.keys(xu).map((e) => ({ id: `distribute-${e}`, group: "arrange", label: () => t.commands.distribute[e], - icon: () => yu[e], + icon: () => xu[e], shortcuts: [], - isEnabled: hu, + isEnabled: _u, run: (t, n) => H(t, (t) => n.distribute(t, e)) -})), Su = [ +})), wu = [ { id: "undo", group: "history", @@ -7790,7 +7922,7 @@ var xl = { label: () => t.commands.delete, icon: () => "mdi:delete-outline", shortcuts: [{ key: "Delete" }, { key: "Backspace" }], - isEnabled: fu, + isEnabled: mu, run: (e, t) => H(e, (e) => t.requestDelete(e)) }, { @@ -7799,7 +7931,7 @@ var xl = { label: ({ item: e }) => e?.hidden ? t.commands.show : t.commands.hide, icon: ({ item: e }) => e?.hidden ? "mdi:eye-off-outline" : "mdi:eye-outline", shortcuts: [], - isEnabled: fu, + isEnabled: mu, run: (e, t) => H(e, (e) => t.toggleFlag(e, "hidden")) }, { @@ -7808,7 +7940,7 @@ var xl = { label: ({ item: e }) => e?.locked ? t.commands.unlock : t.commands.lock, icon: ({ item: e }) => e?.locked ? "mdi:lock" : "mdi:lock-open-variant-outline", shortcuts: [], - isEnabled: fu, + isEnabled: mu, run: (e, t) => H(e, (e) => t.toggleFlag(e, "locked")) }, { @@ -7820,7 +7952,7 @@ var xl = { key: "c", mod: !0 }], - isEnabled: fu, + isEnabled: mu, run: (e, t) => H(e, (e) => t.copy(e)) }, { @@ -7832,7 +7964,7 @@ var xl = { key: "x", mod: !0 }], - isEnabled: fu, + isEnabled: mu, run: (e, t) => H(e, (e) => t.cut(e)) }, { @@ -7865,7 +7997,7 @@ var xl = { key: "d", mod: !0 }], - isEnabled: fu, + isEnabled: mu, run: (e, t) => H(e, (e) => t.duplicate(e)) }, { @@ -7885,7 +8017,7 @@ var xl = { label: () => t.commands.bringToFront, icon: () => "mdi:arrange-bring-to-front", shortcuts: [], - isEnabled: fu, + isEnabled: mu, run: (e, t) => H(e, (e) => t.arrange(e, "front")) }, { @@ -7894,7 +8026,7 @@ var xl = { label: () => t.commands.sendToBack, icon: () => "mdi:arrange-send-to-back", shortcuts: [], - isEnabled: fu, + isEnabled: mu, run: (e, t) => H(e, (e) => t.arrange(e, "back")) }, { @@ -7903,7 +8035,7 @@ var xl = { label: () => t.commands.moveUp, icon: () => "mdi:arrow-up", shortcuts: [], - isEnabled: fu, + isEnabled: mu, run: (e, t) => H(e, (e) => t.arrange(e, "up")) }, { @@ -7912,12 +8044,12 @@ var xl = { label: () => t.commands.moveDown, icon: () => "mdi:arrow-down", shortcuts: [], - isEnabled: fu, + isEnabled: mu, run: (e, t) => H(e, (e) => t.arrange(e, "down")) }, - ...bu(), - ...xu(), - ..._u(), + ...Su(), + ...Cu(), + ...yu(), { id: "group", group: "group", @@ -7975,7 +8107,7 @@ var xl = { label: () => t.commands.deselect, icon: () => "mdi:selection-off", shortcuts: [{ key: "Escape" }], - isEnabled: fu, + isEnabled: mu, isRelevant: () => !1, run: (e, t) => t.deselect() }, @@ -8054,17 +8186,31 @@ var xl = { isEnabled: () => !0, run: (e, t) => t.showShortcuts() } -], Cu = (e) => Su.some((t) => t.id === e), wu = (e) => { - let t = Su.find((t) => t.id === e); +], Tu = /* @__PURE__ */ new Set([ + "undo", + "redo", + "delete-item", + "deselect", + "save", + "toggle-code", + "zoom-in", + "zoom-out", + "zoom-reset", + "show-shortcuts" +]), Eu = wu.map((e) => Tu.has(e.id) ? e : { + ...e, + isEnabled: (t) => !t.basic && e.isEnabled(t) +}), Du = (e) => Eu.some((t) => t.id === e), Ou = (e) => { + let t = Eu.find((t) => t.id === e); if (!t) throw Error(`Unknown command ${e}`); return t; -}, Tu = (e, t) => { +}, ku = (e, t) => { let n = t.ctrlKey || t.metaKey; - return t.key.toLowerCase() === e.key.toLowerCase() && n === !!e.mod && (t.shiftKey === !!e.shift || Eu(e)) && !t.altKey; -}, Eu = (e) => e.key === "?" || e.key === "+", Du = (e, t) => { - let n = Su.filter((t) => t.shortcuts.some((t) => Tu(t, e))); + return t.key.toLowerCase() === e.key.toLowerCase() && n === !!e.mod && (t.shiftKey === !!e.shift || Au(e)) && !t.altKey; +}, Au = (e) => e.key === "?" || e.key === "+", ju = (e, t) => { + let n = Eu.filter((t) => t.shortcuts.some((t) => ku(t, e))); return t ? n.find((e) => e.isEnabled(t)) : n[0]; -}, Ou = { +}, Mu = { Delete: "Del", Backspace: "⌫", Escape: "Esc", @@ -8072,15 +8218,15 @@ var xl = { ArrowRight: "→", ArrowUp: "↑", ArrowDown: "↓" -}, ku = (e, t) => { - let n = Ou[e.key] ?? e.key.toUpperCase(); +}, Nu = (e, t) => { + let n = Mu[e.key] ?? e.key.toUpperCase(); return t ? `${e.mod ? "⌘" : ""}${e.shift ? "⇧" : ""}${n}` : [ e.mod ? "Ctrl" : "", e.shift ? "Shift" : "", n ].filter(Boolean).join("+"); -}, Au = (e, t, n = !1) => { - let r = e.label(t), [i] = e.shortcuts, a = i ? ku(i, n) : ""; +}, Pu = (e, t, n = !1) => { + let r = e.label(t), [i] = e.shortcuts, a = i ? Nu(i, n) : ""; return { label: r, icon: e.icon(t), @@ -8088,7 +8234,7 @@ var xl = { shortcut: a, enabled: e.isEnabled(t) }; -}, ju = [ +}, Fu = [ [ "copy", "cut", @@ -8103,7 +8249,7 @@ var xl = { "enter-group", "exit-group" ] -], Mu = [ +], Iu = [ [ "copy", "cut", @@ -8119,8 +8265,8 @@ var xl = { "enter-group", "exit-group" ] -], Nu = [["paste", "paste-here"]], Pu = (e, t, n) => e.flatMap((e, r) => e.map((e) => wu(e)).filter((e) => e.isRelevant?.(t) ?? !0).map((e, i) => { - let a = Au(e, t, n); +], Lu = [["paste", "paste-here"]], Ru = (e, t, n) => e.flatMap((e, r) => e.map((e) => Ou(e)).filter((e) => e.isRelevant?.(t) ?? !0).map((e, i) => { + let a = Pu(e, t, n); return { id: e.id, label: a.label, @@ -8130,29 +8276,30 @@ var xl = { danger: e.id === "delete-item", separatorBefore: i === 0 && r > 0 }; -})), Fu = [ +})), zu = [ 0, 90, 180, 270 -], Iu = (e) => Fu.some((t) => t === e), Lu = (e) => e === 90 || e === 270, Ru = (e, t) => Lu(t) ? { +], Bu = (e, t) => zu[(zu.indexOf(e) + t + zu.length) % zu.length], Vu = (e) => zu.some((t) => t === e), Hu = (e) => e === 90 || e === 270, Uu = (e, t) => Hu(t) ? { width: e.height, height: e.width } : { width: e.width, height: e.height -}, zu = (e, t, n) => Lu(t) === Lu(n) ? e : { +}, Wu = (e, t, n) => Hu(t) === Hu(n) ? e : { width: e.height, height: e.width -}, Bu = (e, t) => { +}, Gu = (e, t) => { let n = Number(e); - return Iu(n) ? n : t; -}, Vu = (e, t = "custom") => { + return Vu(n) ? n : t; +}, Ku = (e, t = "custom") => { let n = Oo(t); return { id: "", schemaVersion: 1, name: "", + mode: "advanced", status: "draft", language: e || "en", display: { @@ -8164,13 +8311,14 @@ var xl = { padding: 0, snapSize: 5, rotation: 0, - deviceId: null + deviceId: null, + mediaAsDesigned: !1 }, items: [], createdAt: "", updatedAt: "" }; -}, Hu = (e) => ({ +}, qu = (e) => ({ name: e.name, width: e.display.width, height: e.display.height, @@ -8178,51 +8326,52 @@ var xl = { background: e.display.background, rotation: String(e.display.rotation), padding: e.display.padding, - snapSize: e.display.snapSize -}), Uu = (e, t) => { + snapSize: e.display.snapSize, + mediaAsDesigned: e.display.mediaAsDesigned +}), Ju = (e, t) => { let n = structuredClone(e); return Object.assign(n.display, t), xo[t.palette].includes(n.display.background) || (n.display.background = "white"), n; -}, Wu = (e, t) => Uu(e, { +}, Yu = (e, t) => Ju(e, { profileId: "custom", deviceId: t.id, - ...Ru(t, e.display.rotation), + ...Uu(t, e.display.rotation), palette: t.palette -}), Gu = (e, t, n = t.defaultPalette) => Uu(e, { +}), Xu = (e, t, n = t.defaultPalette) => Ju(e, { profileId: t.id, deviceId: null, - ...Ru(t, e.display.rotation), + ...Uu(t, e.display.rotation), palette: t.palettes.includes(n) ? n : t.defaultPalette -}), Ku = (e, t) => { +}), Zu = (e, t) => { let n = { - ...Hu(e), + ...qu(e), ...t }, r = structuredClone(e); r.name = String(n.name); let i = { width: Math.round(Number(n.width) || 0), height: Math.round(Number(n.height) || 0) - }, a = i.width !== e.display.width || i.height !== e.display.height, o = Bu(n.rotation, e.display.rotation), s = a ? i : zu(i, e.display.rotation, o); - a && (r.display.profileId = "custom", r.display.deviceId = null), r.display.width = s.width, r.display.height = s.height, r.display.rotation = o, r.display.palette = n.palette in bo ? n.palette : "bw", r.display.padding = Math.round(Number(n.padding) || 0), r.display.snapSize = Math.round(Number(n.snapSize) || 0); + }, a = i.width !== e.display.width || i.height !== e.display.height, o = Gu(n.rotation, e.display.rotation), s = a ? i : Wu(i, e.display.rotation, o); + a && (r.display.profileId = "custom", r.display.deviceId = null), r.display.width = s.width, r.display.height = s.height, r.display.rotation = o, r.display.palette = n.palette in bo ? n.palette : "bw", r.display.padding = Math.round(Number(n.padding) || 0), r.display.snapSize = Math.round(Number(n.snapSize) || 0), r.display.mediaAsDesigned = n.mediaAsDesigned === !0; let c = xo[r.display.palette]; return r.display.background = c.includes(n.background) ? n.background : "white", r; -}, qu = (e) => { +}, Qu = (e) => { let { width: t, height: n, padding: r, snapSize: i } = e.display; return !!e.name.trim() && t >= 64 && t <= 4096 && n >= 64 && n <= 4096 && r >= 0 && r * 2 < Math.min(t, n) && i >= 1 && i <= 256; -}, Ju = (e, t, n, r) => { +}, $u = (e, t, n, r) => { let i = t.trim().toLocaleLowerCase(r); return e.filter((e) => !i || e.name.toLocaleLowerCase(r).includes(i)).sort((e, t) => n === "name" ? e.name.localeCompare(t.name, r) : t.updatedAt.localeCompare(e.updatedAt) || e.name.localeCompare(t.name, r)); -}, Yu = (e, t, n) => { +}, ed = (e, t, n) => { let r = new Set(t.map((e) => e.name.toLocaleLowerCase(n))), i = `${e.name} copy`, a = i, o = 2; for (; r.has(a.toLocaleLowerCase(n));) a = `${i} ${o++}`; return a; -}, Xu = Object.keys(bo).filter(ko), Zu = Do.filter((e) => e.id !== "custom"), Qu = { +}, td = Object.keys(bo).filter(ko), nd = Do.filter((e) => e.id !== "custom"), rd = { size: !0, - palettes: Xu, + palettes: td, backgrounds: [] -}, $u = (e) => ({ - ...Qu, +}, id = (e) => ({ + ...rd, backgrounds: xo[e.display.palette] -}), ed = (e, t) => ({ +}), ad = (e, t) => ({ name: e, label: t, required: !0, @@ -8232,12 +8381,12 @@ var xl = { max: 4096, unit_of_measurement: "px" } } -}), td = () => [{ +}), od = () => [{ name: "dimensions", type: "grid", flatten: !0, - schema: [ed("width", t.fields.width), ed("height", t.fields.height)] -}], nd = (e) => [{ + schema: [ad("width", t.fields.width), ad("height", t.fields.height)] +}], sd = (e) => [{ name: "palette", label: t.fields.palette, required: !0, @@ -8248,7 +8397,7 @@ var xl = { label: bo[e] })) } } -}], rd = (e) => [{ +}], cd = (e) => [{ name: "background", label: t.fields.background, required: !0, @@ -8259,18 +8408,22 @@ var xl = { label: `${e[0].toUpperCase()}${e.slice(1)}` })) } } -}], id = () => [{ +}], ld = () => [{ name: "rotation", label: t.fields.rotation, required: !0, selector: { select: { mode: "dropdown", - options: Fu.map((e) => ({ + options: zu.map((e) => ({ value: String(e), label: t.rotations[e] })) } } -}], ad = () => [{ +}], ud = "mediaAsDesigned", dd = () => [{ + name: ud, + label: t.fields.mediaAsDesigned, + selector: { boolean: {} } +}], fd = (e) => (n) => "name" in n && n.name === "mediaAsDesigned" ? t.fields.mediaAsDesignedHelp(e) : "", pd = () => [{ name: "advanced", type: "expandable", flatten: !0, @@ -8295,97 +8448,225 @@ var xl = { unit_of_measurement: "px" } } }] -}], od = (e = Qu) => [ - { - name: "name", - label: t.fields.name, - required: !0, - selector: { text: {} } - }, - ...e.size ? td() : [], - ...e.palettes.length > 1 ? nd(e.palettes) : [], - ...e.backgrounds.length > 0 ? rd(e.backgrounds) : [], - ...id(), - ...ad() -], sd = (e) => "label" in e ? e.label : e.title ?? "", cd = { +}], md = () => [{ + name: "name", + label: t.fields.name, + required: !0, + selector: { text: {} } +}], hd = (e = rd) => [ + ...md(), + ...e.size ? od() : [], + ...e.palettes.length > 1 ? sd(e.palettes) : [], + ...e.backgrounds.length > 0 ? cd(e.backgrounds) : [], + ...ld(), + ...dd(), + ...pd() +], gd = (e) => "label" in e ? e.label : e.title ?? "", _d = { bwr: "#d32f2f", bwry: "#d32f2f", bwy: "#d6a800", spectra6: "#246bfd", seven_color: "#ff8000" -}, ld = (e) => cd[e] ?? "#202124", ud = (e, t) => { +}, vd = (e) => _d[e] ?? "#202124", yd = (e, t) => { let n = new Date(e.updatedAt); return Number.isNaN(n.getTime()) ? "" : new Intl.DateTimeFormat(t, { dateStyle: "medium" }).format(n); -}, dd = (e) => e.target.value, fd = (e) => e.composedPath().some((e) => e instanceof HTMLElement && (e.matches("input, textarea, select") || e.isContentEditable)), pd = () => /Mac|iPhone|iPad/.test(navigator.platform), md = (e) => e.shiftKey || e.ctrlKey || e.metaKey, hd = (e, t, n = "application/json") => { +}, bd = (e) => e.target.value, xd = (e) => e.composedPath().some((e) => e instanceof HTMLElement && (e.matches("input, textarea, select") || e.isContentEditable)), Sd = () => /Mac|iPhone|iPad/.test(navigator.platform), Cd = (e) => e.shiftKey || e.ctrlKey || e.metaKey, wd = (e, t, n = "application/json") => { let r = URL.createObjectURL(new Blob([t], { type: n })), i = document.createElement("a"); i.href = r, i.download = e, i.click(), URL.revokeObjectURL(r); -}, gd = (e) => new Promise((t) => { +}, Td = (e) => new Promise((t) => { let n = document.createElement("input"); n.type = "file", n.accept = e, n.addEventListener("change", () => t(n.files?.[0])), n.addEventListener("cancel", () => t(void 0)), n.click(); -}), _d = "dashboard", vd = (e) => `${e.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/ł/g, "l").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || _d}.json`, yd = (e) => Object.fromEntries(e.map((e) => [e.source, e.suggestion])), bd = (e) => { +}), Ed = "dashboard", Dd = (e) => `${e.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/ł/g, "l").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || Ed}.json`, Od = (e) => Object.fromEntries(e.map((e) => [e.source, e.suggestion])), kd = (e) => { try { return JSON.parse(e); } catch { return; } -}, xd = (e) => typeof e == "string" && e !== "" ? e : void 0, U = (e, t) => e instanceof Error || typeof e == "object" && e && "message" in e ? xd(e.message) ?? t : xd(e) ?? t, Sd = (e) => { - if (typeof e == "object" && e && "code" in e) return xd(e.code); -}, Cd = (e) => e.callWS({ +}, Ad = (e) => typeof e == "string" && e !== "" ? e : void 0, U = (e, t) => e instanceof Error || typeof e == "object" && e && "message" in e ? Ad(e.message) ?? t : Ad(e) ?? t, jd = (e) => { + if (typeof e == "object" && e && "code" in e) return Ad(e.code); +}, Md = (e) => e.callWS({ type: "opendisplay_studio/bootstrap", language: e.language -}), wd = (e) => e.callWS({ +}), Nd = (e, t, n) => e.callWS({ + type: "opendisplay_studio/layout_frames", + width: t.width, + height: t.height, + padding: t.padding, + layout: n +}), Pd = (e) => e.callWS({ type: "opendisplay_studio/reload_widgets", language: e.language -}), Td = async (e, t) => (await e.callWS({ +}), Fd = async (e, t) => (await e.callWS({ type: "opendisplay_studio/create_dashboard", dashboard: t -})).dashboard, Ed = async (e, t) => (await e.callWS({ +})).dashboard, Id = async (e, t) => (await e.callWS({ type: "opendisplay_studio/update_dashboard", dashboard_id: t.id, dashboard: t -})).dashboard, Dd = async (e, t) => { +})).dashboard, Ld = async (e, t) => { await e.callWS({ type: "opendisplay_studio/delete_dashboard", dashboard_id: t }); -}, Od = (e, t) => e.callWS({ +}, Rd = (e, t) => e.callWS({ type: "opendisplay_studio/compose_preview", dashboard: structuredClone(t) -}), kd = async (e) => (await e.callWS({ type: "opendisplay_studio/list_devices" })).devices, Ad = async (e, t) => { +}), zd = async (e) => (await e.callWS({ type: "opendisplay_studio/list_devices" })).devices, Bd = async (e, t) => { await e.callWS({ type: "opendisplay_studio/send_to_device", dashboard: structuredClone(t) }); -}, jd = async (e, t) => (await e.callWS({ +}, Vd = async (e, t) => (await e.callWS({ type: "opendisplay_studio/export_dashboard", dashboard: structuredClone(t) -})).file, Md = (e, t, n, r = {}) => e.callWS({ +})).file, Hd = (e, t, n, r = {}) => e.callWS({ type: "opendisplay_studio/prepare_import", file: t, display: n, colorMap: r -}), Nd, Pd = (e) => (Nd ??= e.callWS({ type: "opendisplay_studio/list_icons" }).then((e) => ({ +}), Ud, Wd = (e) => (Ud ??= e.callWS({ type: "opendisplay_studio/list_icons" }).then((e) => ({ names: e.icons, glyphs: Object.fromEntries(Object.entries(e.glyphs).map(([e, t]) => [e, String.fromCodePoint(t)])) -})), Nd), Fd = async (e) => (await e.callWS({ type: "opendisplay_studio/list_fonts" })).fonts, Id = async (e, t, n) => (await e.callWS({ +})), Ud), Gd = async (e) => (await e.callWS({ type: "opendisplay_studio/list_fonts" })).fonts, Kd = async (e, t, n) => (await e.callWS({ type: "opendisplay_studio/install_font", filename: t, data: n -})).fonts, Ld = async (e, t, n) => (await e.callWS({ +})).fonts, qd = async (e, t, n) => (await e.callWS({ type: "opendisplay_studio/delete_font", name: t, force: n -})).fonts, Rd, zd = (e) => (Rd ??= Fd(e).catch((e) => { - throw Rd = void 0, e; -}), Rd), Bd = (e) => { - Rd = Promise.resolve(e); -}, Vd = (e) => [e.family, e.style].filter((e) => e !== "" && e !== "Regular").join(" "), Hd = (e, t) => e.find((e) => e.name === t), W = (e, t, ...n) => { +})).fonts, Jd, Yd = (e) => (Jd ??= Gd(e).catch((e) => { + throw Jd = void 0, e; +}), Jd), Xd = (e) => { + Jd = Promise.resolve(e); +}, Zd = (e) => [e.family, e.style].filter((e) => e !== "" && e !== "Regular").join(" "), Qd = (e, t) => e.find((e) => e.name === t), W = (e, t, ...n) => { e.dispatchEvent(new CustomEvent(t, { detail: n[0], bubbles: !0, composed: !0 })); -}, Ud = 100, Wd = class { +}, $d = 26, ef = (e) => { + let t = String.fromCharCode(65 + e % $d); + return e < $d ? t : ef(Math.floor(e / $d) - 1) + t; +}, tf = "region-", nf = (e) => { + let t = 1; + for (; e.has(`${tf}${t}`);) t += 1; + let n = `${tf}${t}`; + return e.add(n), n; +}, rf = (e) => e.rowSpan > 1 || e.columnSpan > 1, af = (e, t) => e.row - t.row || e.column - t.column, of = (e) => { + let t = []; + for (let n = e.row; n < e.row + e.rowSpan; n += 1) for (let r = e.column; r < e.column + e.columnSpan; r += 1) t.push({ + row: n, + column: r + }); + return t; +}, sf = (e, t) => e.regions.find((e) => of(e).some((e) => e.row === t.row && e.column === t.column)), cf = (e, t) => ({ + row: Math.min(e.row, t.row), + column: Math.min(e.column, t.column), + rowSpan: Math.abs(e.row - t.row) + 1, + columnSpan: Math.abs(e.column - t.column) + 1 +}), lf = (e) => { + let t = [...e.regions], n = new Set(t.map((e) => e.id)); + for (let r = 0; r < e.rows; r += 1) for (let i = 0; i < e.columns; i += 1) sf({ + ...e, + regions: t + }, { + row: r, + column: i + }) || t.push({ + id: nf(n), + row: r, + column: i, + rowSpan: 1, + columnSpan: 1 + }); + return uf(t); +}, uf = (e) => [...e.filter(rf), ...e.filter((e) => !rf(e)).sort(af)], df = (e, t, n) => ({ + columns: e, + rows: t, + gap: n, + regions: lf({ + columns: e, + rows: t, + gap: n, + regions: [] + }) +}), ff = (e, t) => e.row >= t.row && e.column >= t.column && e.row + e.rowSpan <= t.row + t.rowSpan && e.column + e.columnSpan <= t.column + t.columnSpan, pf = (e, t) => e.row < t.row + t.rowSpan && e.row + e.rowSpan > t.row && e.column < t.column + t.columnSpan && e.column + e.columnSpan > t.column, mf = (e, t) => e.regions.filter((e) => pf(e, t)).sort(af), hf = (e, t) => { + let n = mf(e, t); + return n.length > 1 && n.every((e) => ff(e, t)); +}, gf = (e, t, n) => { + let r = mf(e, t), i = r.find((e) => n.has(e.id)) ?? r[0], a = r.filter((e) => e.id !== i.id), o = e.regions.filter((e) => !r.includes(e)).concat({ + id: i.id, + ...t + }); + return { + layout: { + ...e, + regions: uf(o) + }, + removedRegionIds: a.map((e) => e.id) + }; +}, _f = (e, t) => { + let n = e.regions.find((e) => e.id === t); + if (!n) return e; + let r = e.regions.filter((e) => e !== n), i = { + id: n.id, + row: n.row, + column: n.column, + rowSpan: 1, + columnSpan: 1 + }, a = lf({ + ...e, + regions: [...r, i] + }); + return { + ...e, + regions: a + }; +}, vf = (e, t, n) => { + let r = { + row: 0, + column: 0, + rowSpan: n, + columnSpan: t + }, i = e.regions.filter((e) => ff(e, r)), a = e.regions.filter((e) => !i.includes(e)), o = { + ...e, + columns: t, + rows: n, + regions: i + }; + return { + layout: { + ...o, + regions: lf(o) + }, + removedRegionIds: a.map((e) => e.id) + }; +}, yf = (e, t) => e.regions.find((e) => t.x >= e.x && t.x < e.x + e.width && t.y >= e.y && t.y < e.y + e.height)?.id, bf = (e, t, n) => { + let r = structuredClone(e); + return r.mode = t, t === "basic" ? r.layout = df(2, 2, n) : delete r.layout, r; +}, xf = (e) => e.items.filter((e) => e.kind === "widget"), Sf = (e, t) => xf(e).find((e) => e.region === t), Cf = (e) => new Set(xf(e).flatMap((e) => e.region ?? [])), wf = (e, t, n) => { + let r = Cf(e), i = t.regions.filter((e) => !r.has(e.id)); + return i.find((e) => e.id === n) ?? i[0]; +}, Tf = (e, t, n, r) => { + let i = Kl(t, 0, 0, e); + return i.region = n.id, i.frame = { ...r }, e.items.push(i), i; +}, Ef = (e, t) => e.filter((e) => e.kind !== "widget" || !t.includes(e.region ?? "")), Df = (e, t) => { + e.layout = t.layout, e.items = Ef(e.items, t.removedRegionIds); +}, Of = (e, t) => { + if (!e.layout) return; + let n = Cf(e); + Df(e, gf(e.layout, t, n)); +}, kf = (e, t) => { + e.layout &&= _f(e.layout, t); +}, Af = (e, t) => { + e.layout && Df(e, vf(e.layout, t.columns, t.rows)); +}, jf = (e, t) => { + e.layout &&= { + ...e.layout, + gap: t + }; +}, Mf = 100, Nf = class { constructor() { this.undoStack = [], this.redoStack = []; } @@ -8396,7 +8677,7 @@ var xl = { return this.redoStack.length; } record(e) { - this.undoStack.push(structuredClone(e)), this.undoStack.length > Ud && this.undoStack.shift(), this.redoStack = []; + this.undoStack.push(structuredClone(e)), this.undoStack.length > Mf && this.undoStack.shift(), this.redoStack = []; } undo(e) { let t = this.undoStack.pop(); @@ -8409,7 +8690,7 @@ var xl = { clear() { this.undoStack = [], this.redoStack = []; } -}, Gd = (e, t) => { +}, Pf = (e, t) => { let n = w(e.items, t); if (!n) return; let { parent: r } = n; @@ -8419,8 +8700,8 @@ var xl = { width: r.width, height: r.height } : pt(e); -}, Kd = (e, t, n, r) => { - let i = w(e.items, t), a = Gd(e, t); +}, Ff = (e, t, n, r) => { + let i = w(e.items, t), a = Pf(e, t); if (!i || !a || i.item.locked) return; let o = x(i.item, r), s = ce(n, "lt"), c = a.x + (a.width - o.width) * s.x, l = a.y + (a.height - o.height) * s.y; S(i.item, Math.round(c - o.x), Math.round(l - o.y)); @@ -8495,11 +8776,10 @@ var xl = { ha-dialog { --dialog-content-padding: 0; } -`, qd = P` +`, If = P` .dashboard-card, .dashboard-add-card { min-width: 0; - min-height: 236px; padding: 0; border: 1px solid var(--studio-border); border-radius: 13px; @@ -8507,6 +8787,10 @@ var xl = { background: var(--studio-surface); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); } + /* A card is as tall as its picture and its two lines; the add card matches it. */ + .dashboard-add-card { + min-height: 200px; + } .dashboard-card:hover, .dashboard-card:focus-within, .dashboard-add-card:hover, @@ -8520,7 +8804,7 @@ var xl = { outline: 0; transform: translateY(-1px); } -`, Jd = P` +`, Lf = P` .box { display: flex; align-items: center; @@ -8614,9 +8898,9 @@ var xl = { background: var(--primary-color); color: var(--text-primary-color, #fff); } -`, Yd = Sl(class extends Cl { +`, Rf = wl(class extends Tl { constructor(e) { - if (super(e), e.type !== xl.ATTRIBUTE || e.name !== "class" || e.strings?.length > 2) throw Error("`classMap()` can only be used in the `class` attribute and must be the only part in the attribute."); + if (super(e), e.type !== Cl.ATTRIBUTE || e.name !== "class" || e.strings?.length > 2) throw Error("`classMap()` can only be used in the `class` attribute and must be the only part in the attribute."); } render(e) { return " " + Object.keys(e).filter((t) => e[t]).join(" ") + " "; @@ -8633,18 +8917,18 @@ var xl = { let r = !!t[e]; r === this.st.has(e) || this.nt?.has(e) || (r ? (n.add(e), this.st.add(e)) : (n.remove(e), this.st.delete(e))); } - return $c; + return tl; } -}), Xd = 2, q = (e, t) => t === "x" ? e.x : e.y, J = (e, t) => t === "x" ? e.width : e.height, Zd = (e, t) => { +}), zf = 2, q = (e, t) => t === "x" ? e.x : e.y, J = (e, t) => t === "x" ? e.width : e.height, Bf = (e, t) => { let n = q(e, t), r = J(e, t); return [ n, n + r / 2, n + r ]; -}, Qd = (e, t, n) => { +}, Vf = (e, t, n) => { let r = [], i = (t, i, a) => { - for (let o of Zd(t, n)) Zd(e, n).forEach((e, t) => { + for (let o of Bf(t, n)) Bf(e, n).forEach((e, t) => { let n = Math.abs(o - e); n <= i && r.push({ offset: Math.round(o - e), @@ -8655,9 +8939,9 @@ var xl = { }); }); }; - return t.siblings.forEach((e) => i(e, 5, "sibling")), i(t.parent, 8, "parent"), r.push(...ef(e, t.siblings, n)), r.sort(nf)[0]; -}, $d = (e, t, n) => q(e, n) < q(t, n) + J(t, n) && q(t, n) < q(e, n) + J(e, n), ef = (e, t, n) => { - let r = n === "x" ? "y" : "x", i = t.filter((t) => $d(t, e, r)), a = []; + return t.siblings.forEach((e) => i(e, 5, "sibling")), i(t.parent, 8, "parent"), r.push(...Uf(e, t.siblings, n)), r.sort(Gf)[0]; +}, Hf = (e, t, n) => q(e, n) < q(t, n) + J(t, n) && q(t, n) < q(e, n) + J(e, n), Uf = (e, t, n) => { + let r = n === "x" ? "y" : "x", i = t.filter((t) => Hf(t, e, r)), a = []; for (let t of i) for (let r of i) { let i = q(r, n) - (q(t, n) + J(t, n)) - J(e, n); if (i < 0) continue; @@ -8669,22 +8953,22 @@ var xl = { }); } return a; -}, tf = (e) => e.distance - (e.source === "parent" ? Xd : 0), nf = (e, t) => tf(e) - tf(t) || Number(t.source === "parent") - Number(e.source === "parent"), rf = (e, t, n) => ({ - dx: af(e, t, "x", n), - dy: af(e, t, "y", n) -}), af = (e, t, n, r) => { - let i = Qd(e, t, n), a = r?.[n]; +}, Wf = (e) => e.distance - (e.source === "parent" ? zf : 0), Gf = (e, t) => Wf(e) - Wf(t) || Number(t.source === "parent") - Number(e.source === "parent"), Kf = (e, t, n) => ({ + dx: qf(e, t, "x", n), + dy: qf(e, t, "y", n) +}), qf = (e, t, n, r) => { + let i = Vf(e, t, n), a = r?.[n]; if (a) { - let t = Zd(e, n)[a.own], r = Math.abs(a.line - t); + let t = Bf(e, n)[a.own], r = Math.abs(a.line - t); if (r <= 12 && (!i || i.distance >= r)) return Math.round(a.line - t); } return r && (i?.source === "parent" && i.own !== void 0 && i.line !== void 0 ? r[n] = { own: i.own, line: i.line } : delete r[n]), i?.offset ?? 0; -}, of = (e, t, n) => { +}, Jf = (e, t, n) => { let r = [], i = (t, i, a) => { - for (let o of Zd(t, n)) { + for (let o of Bf(t, n)) { let t = Math.abs(o - e); t <= i && r.push({ offset: Math.round(o - e), @@ -8694,25 +8978,25 @@ var xl = { } }; return t.siblings.forEach((e) => i(e, 5, "sibling")), i(t.parent, 8, "parent"), r; -}, sf = (e, t, n) => { +}, Yf = (e, t, n) => { let [r, i] = n === "x" ? ["w", "e"] : ["n", "s"]; if (t.includes(r)) return q(e, n); if (t.includes(i)) return q(e, n) + J(e, n); -}, cf = (e, t, n) => { +}, Xf = (e, t, n) => { let r = (r) => { - let i = sf(e, t, r); - return i === void 0 ? 0 : of(i, n, r).sort(nf)[0]?.offset ?? 0; + let i = Yf(e, t, r); + return i === void 0 ? 0 : Jf(i, n, r).sort(Gf)[0]?.offset ?? 0; }; return { dx: r("x"), dy: r("y") }; -}, lf = .5, uf = (e, t) => { +}, Zf = .5, Qf = (e, t) => { let n = [], r = [...t.siblings, t.parent]; for (let t of ["x", "y"]) { let i = t === "x" ? "y" : "x"; - for (let a of Zd(e, t)) { - let o = r.filter((e) => Zd(e, t).some((e) => Math.abs(e - a) <= lf)); + for (let a of Bf(e, t)) { + let o = r.filter((e) => Bf(e, t).some((e) => Math.abs(e - a) <= Zf)); if (o.length === 0) continue; let s = [e, ...o].map((e) => [q(e, i), q(e, i) + J(e, i)]); n.push({ @@ -8723,17 +9007,17 @@ var xl = { }); } } - return pf(n); -}, df = (e, t, n) => { - let r = n === "x" ? "y" : "x", i = t.filter((t) => $d(t, e, r)), a = (e) => q(e, n) + J(e, n); + return tp(n); +}, $f = (e, t, n) => { + let r = n === "x" ? "y" : "x", i = t.filter((t) => Hf(t, e, r)), a = (e) => q(e, n) + J(e, n); return { before: i.filter((t) => a(t) <= q(e, n)).sort((e, t) => a(t) - a(e))[0], after: i.filter((t) => q(t, n) >= q(e, n) + J(e, n)).sort((e, t) => q(e, n) - q(t, n))[0] }; -}, ff = (e, t) => { +}, ep = (e, t) => { let n = []; for (let r of ["x", "y"]) { - let { before: i, after: a } = df(e, t, r); + let { before: i, after: a } = $f(e, t, r); if (!i || !a) continue; let o = q(i, r) + J(i, r), s = q(a, r), c = q(e, r) - o, l = s - (q(e, r) + J(e, r)); if (Math.abs(c - l) > 3) continue; @@ -8751,19 +9035,19 @@ var xl = { }); } return n; -}, pf = (e) => e.filter((t, n) => e.findIndex((e) => e.axis === t.axis && Math.abs(e.position - t.position) <= lf) === n), mf = (e, t, n) => n?.absolute ? { +}, tp = (e) => e.filter((t, n) => e.findIndex((e) => e.axis === t.axis && Math.abs(e.position - t.position) <= Zf) === n), np = (e, t, n) => n?.absolute ? { x: n.x, y: n.y, width: n.width, height: n.height -} : At(x(e, n), t), hf = (e, t, n) => t.flatMap((t) => { +} : At(x(e, n), t), rp = (e, t, n) => t.flatMap((t) => { let r = w(e.items, t); return r ? [{ original: structuredClone(r.item), offset: r.offset, measured: n(r.item) }] : []; -}), gf = (e) => Qt(e.map((e) => At(x(e.original, e.measured), e.offset))), _f = (e, t, n, r) => At(x(t, n && e.kind === "primitive" && t.kind === "primitive" ? je(e.primitive, t.primitive, n) : n), r), vf = (e, t, n, r, i, a, o) => { +}), ip = (e) => Qt(e.map((e) => At(x(e.original, e.measured), e.offset))), ap = (e, t, n, r) => At(x(t, n && e.kind === "primitive" && t.kind === "primitive" ? je(e.primitive, t.primitive, n) : n), r), op = (e, t, n, r, i, a, o) => { let s = a.offset ?? { x: 0, y: 0 @@ -8774,36 +9058,36 @@ var xl = { }, r, o, i, { ...a, snapEnabled: s - }), l = (t) => _f(e, t, a.measured, s), u = c(r.dx, r.dy, a.snapEnabled); + }), l = (t) => ap(e, t, a.measured, s), u = c(r.dx, r.dy, a.snapEnabled); if (!o || !a.snapEnabled) return { item: u, guides: [] }; - let d = l(u), f = cf(d, t, o); + let d = l(u), f = Xf(d, t, o); if (f.dx === 0 && f.dy === 0) return { item: u, - guides: uf(d, o) + guides: Qf(d, o) }; - let p = l(e), m = (e) => (sf(d, t, e) ?? 0) - (sf(p, t, e) ?? 0), h = c(m("x") + f.dx, m("y") + f.dy, !1); + let p = l(e), m = (e) => (Yf(d, t, e) ?? 0) - (Yf(p, t, e) ?? 0), h = c(m("x") + f.dx, m("y") + f.dy, !1); return { item: h, - guides: uf(l(h), o) + guides: Qf(l(h), o) }; -}, yf = (e, t, n, r) => { +}, sp = (e, t, n, r) => { let i = w(e.items, n); if (!i) return; let a = i.parent?.id, o = zt(e.items).filter((e) => e.parent?.id === a).filter((e) => !e.item.hidden).filter((e) => !t.includes(e.item.id)).flatMap((e) => { let t = C(e.item) ? Bt(e) : At(x(e.item, r(e.item)), e.offset); return t ? [t] : []; - }), s = i.parent ? bf(e, i.parent.id) : pt(e); + }), s = i.parent ? cp(e, i.parent.id) : pt(e); return s ? { siblings: o, parent: s } : void 0; -}, bf = (e, t) => { +}, cp = (e, t) => { let n = w(e.items, t); return n && C(n.item) ? At(x(n.item), n.offset) : void 0; -}, xf = (e, t, n, r, i, a, o, s) => { +}, lp = (e, t, n, r, i, a, o, s) => { let c = e.find((e) => e.original.id === t); if (!c) return []; let l = St(c.original, { mode: "move" }, n, r, i, { @@ -8812,7 +9096,7 @@ var xl = { measured: c.measured }), u = x(c.original, c.measured), d = x(l, c.measured), f = d.x - u.x, p = d.y - u.y; if (o && a && !c.original.locked) { - let t = gf(e), i = rf({ + let t = ip(e), i = Kf({ ...t, x: t.x + n, y: t.y + r @@ -8824,36 +9108,36 @@ var xl = { let n = structuredClone(e.original); return n.locked || S(n, f, p), n; }); -}, Sf = (e, t, n) => { +}, up = (e, t, n) => { let r = e.flatMap((e) => { let n = t.find((t) => t.id === e.original.id); return n ? [At(x(n, e.measured), e.offset)] : []; }); - return r.length > 0 ? uf(Qt(r), n) : []; -}, Cf = (e, t, n) => { + return r.length > 0 ? Qf(Qt(r), n) : []; +}, dp = (e, t, n) => { let r = e.flatMap((e) => { let n = t.find((t) => t.id === e.original.id); return n ? [At(x(n, e.measured), e.offset)] : []; }); - return r.length > 0 ? ff(Qt(r), n.siblings) : []; -}, wf = (e, t, n) => { + return r.length > 0 ? ep(Qt(r), n.siblings) : []; +}, fp = (e, t, n) => { let r = w(e.items, t); - if (r) return mf(r.item, r.offset, n(r.item)); -}, Tf = (e, t, n) => { - let r = t.flatMap((t) => wf(e, t, n) ?? []); + if (r) return np(r.item, r.offset, n(r.item)); +}, pp = (e, t, n) => { + let r = t.flatMap((t) => fp(e, t, n) ?? []); return r.length > 0 ? Qt(r) : void 0; -}, Ef = (e, t, n, r) => { +}, mp = (e, t, n, r) => { let i = n ? w(e.items, n)?.item : void 0, a = i && C(i) ? i.id : void 0; return zt(e.items).filter((e) => (e.parent?.id ?? void 0) === a).filter((e) => !e.item.hidden).filter((e) => { - let n = C(e.item) ? Bt(e) : mf(e.item, e.offset, r(e.item)); + let n = C(e.item) ? Bt(e) : np(e.item, e.offset, r(e.item)); return n !== void 0 && Mt(n, t); }).map((e) => e.item.id); -}, Df = (e, t) => ({ +}, hp = (e, t) => ({ x: Math.min(e.x, t.x), y: Math.min(e.y, t.y), width: Math.abs(t.x - e.x), height: Math.abs(t.y - e.y) -}), Of = (e) => { +}), gp = (e) => { let { origin: t, threshold: n = 0, target: r = window, onActivate: i, onMove: a, onEnd: o, onCancel: s } = e, c = !1, l = () => { r.removeEventListener("pointermove", u), r.removeEventListener("pointerup", d), r.removeEventListener("pointercancel", f); }, u = (e) => { @@ -8868,18 +9152,18 @@ var xl = { l(), s?.(); }; return r.addEventListener("pointermove", u), r.addEventListener("pointerup", d), r.addEventListener("pointercancel", f), l; -}, kf = (e) => Array.isArray(e) && e.length === 2 && e.every((e) => typeof e == "number"), Af = (e) => Array.isArray(e) ? e.filter(kf).map(([e, t]) => [e, t]) : [], jf = (e) => e.length < 256, Mf = (e) => e.length > 3, Nf = (e) => { - if (!jf(e) || e.length === 0) return e; +}, _p = (e) => Array.isArray(e) && e.length === 2 && e.every((e) => typeof e == "number"), vp = (e) => Array.isArray(e) ? e.filter(_p).map(([e, t]) => [e, t]) : [], yp = (e) => e.length < 256, bp = (e) => e.length > 3, xp = (e) => { + if (!yp(e) || e.length === 0) return e; let [t, n] = e[e.length - 1], [r, i] = e[0]; return [...e, [Math.round((t + r) / 2), Math.round((n + i) / 2)]]; -}, Pf = (e, t) => Mf(e) ? e.filter((e, n) => n !== t) : e, Ff = (e, t, n, r) => e.map((e, i) => i === t ? n === 0 ? [r, e[1]] : [e[0], r] : e), If = (e, t, n) => e.map((e, r) => r === t ? n : e), Lf = (e, t, n) => jf(e) ? [ +}, Sp = (e, t) => bp(e) ? e.filter((e, n) => n !== t) : e, Cp = (e, t, n, r) => e.map((e, i) => i === t ? n === 0 ? [r, e[1]] : [e[0], r] : e), wp = (e, t, n) => e.map((e, r) => r === t ? n : e), Tp = (e, t, n) => yp(e) ? [ ...e.slice(0, t + 1), n, ...e.slice(t + 1) -] : e, Rf = (e, t) => [Math.round((e[0] + t[0]) / 2), Math.round((e[1] + t[1]) / 2)], zf = (e) => { - if (e.type === "polygon") return Af(e.points); +] : e, Ep = (e, t) => [Math.round((e[0] + t[0]) / 2), Math.round((e[1] + t[1]) / 2)], Dp = (e) => { + if (e.type === "polygon") return vp(e.points); if (e.type === "line") return [[e.x_start, e.y_start], [e.x_end, e.y_end]]; -}, Bf = (e, t) => { +}, Op = (e, t) => { if (e.type === "polygon") return { ...e, points: t @@ -8905,13 +9189,303 @@ function Y(e, t, n, r) { return i > 3 && a && Object.defineProperty(t, n, a), a; } //#endregion +//#region src/ods-region-layer.ts +var kp = (e) => ({ + left: `${e.x}px`, + top: `${e.y}px`, + width: `${e.width}px`, + height: `${e.height}px` +}), Ap = class extends L { + constructor(...e) { + super(...e), this.step = "layout", this.selectedRegionId = "", this.onKeyDown = (e) => { + e.key === "Escape" && this.corner && this.stopJoining(); + }; + } + static { + this.styles = [G, P` + :host { + position: absolute; + inset: 0; + pointer-events: none; + } + button { + position: absolute; + box-sizing: border-box; + padding: 0; + pointer-events: auto; + cursor: pointer; + background: transparent; + color: var(--studio-text); + } + .cell { + border: calc(1px * var(--ui, 1)) dashed var(--studio-accent); + } + .cell.joined { + border-color: transparent; + } + .cell:hover, + .cell.in-rectangle { + background: var(--studio-accent-soft); + } + .cell.corner { + background: var(--studio-accent-soft); + border-style: solid; + } + .cell.in-rectangle.invalid { + background: color-mix( + in srgb, + var(--error-color, #db4437) 18%, + transparent + ); + border-color: var(--error-color, #db4437); + } + .outline { + position: absolute; + box-sizing: border-box; + border: calc(2px * var(--ui, 1)) solid var(--studio-accent); + pointer-events: none; + } + .outline { + display: grid; + place-content: center; + justify-items: center; + gap: calc(2px * var(--ui, 1)); + color: var(--studio-text); + } + .letter { + font-size: calc(26px * var(--ui, 1)); + font-weight: 700; + } + .size { + color: var(--studio-muted); + font: calc(10px * var(--ui, 1)) var(--code-font-family, monospace); + } + .outline.selected { + background: var(--studio-accent-soft); + box-shadow: 0 0 0 calc(2px * var(--ui, 1)) var(--studio-accent); + } + /* A region with a widget draws nothing of its own, so the picture is what will be + rendered; only an empty region is marked, and the selected one is outlined. */ + .slot { + border: 0; + } + .slot:hover { + box-shadow: inset 0 0 0 calc(1px * var(--ui, 1)) var(--studio-accent); + } + .slot.empty { + border: calc(1px * var(--ui, 1)) dashed var(--studio-accent); + background: color-mix(in srgb, var(--studio-surface) 70%, transparent); + } + .slot.empty:hover { + background: var(--studio-accent-soft); + box-shadow: none; + } + .slot.selected { + border: calc(2px * var(--ui, 1)) solid var(--studio-accent); + box-shadow: none; + } + .slot.empty.selected { + background: var(--studio-accent-soft); + } + .slot-label { + position: absolute; + top: 0; + left: 0; + max-width: 100%; + padding: calc(2px * var(--ui, 1)) calc(6px * var(--ui, 1)); + overflow: hidden; + background: var(--studio-accent); + color: var(--studio-on-accent, #fff); + font-size: calc(11px * var(--ui, 1)); + text-overflow: ellipsis; + white-space: nowrap; + } + .slot-empty { + display: grid; + gap: calc(4px * var(--ui, 1)); + place-items: center; + font-size: calc(12px * var(--ui, 1)); + } + .slot-empty ha-icon { + --mdc-icon-size: calc(28px * var(--ui, 1)); + color: var(--studio-accent); + } + .status { + position: absolute; + bottom: calc(8px * var(--ui, 1)); + left: 50%; + padding: calc(4px * var(--ui, 1)) calc(10px * var(--ui, 1)); + border-radius: 999px; + background: var(--studio-text); + color: var(--studio-surface); + font-size: calc(12px * var(--ui, 1)); + transform: translateX(-50%); + white-space: nowrap; + } + `]; + } + connectedCallback() { + super.connectedCallback(), window.addEventListener("keydown", this.onKeyDown); + } + disconnectedCallback() { + super.disconnectedCallback(), window.removeEventListener("keydown", this.onKeyDown); + } + willUpdate(e) { + e.has("step") && this.stopJoining(); + } + get layout() { + return this.dashboard.layout; + } + hoverCell(e) { + this.hover = e; + } + stopJoining() { + this.corner = void 0, this.hover = void 0; + } + cellAt(e, t) { + return { + row: Math.floor(e / t.columns), + column: e % t.columns + }; + } + isCorner(e) { + return this.corner?.row === e.row && this.corner.column === e.column; + } + get joining() { + if (!(!this.corner || !this.hover)) return cf(this.corner, this.hover); + } + isInJoin(e) { + let t = this.joining; + return t ? e.row >= t.row && e.row < t.row + t.rowSpan && e.column >= t.column && e.column < t.column + t.columnSpan : !1; + } + isInJoinedRegion(e, t) { + let n = sf(t, e); + return n !== void 0 && (n.rowSpan > 1 || n.columnSpan > 1); + } + joinIsValid(e) { + let t = this.joining; + return t !== void 0 && hf(e, t); + } + onCellClick(e, t) { + let n = sf(t, e); + if (!this.corner) { + this.corner = e, n && W(this, "region-select", { regionId: n.id }); + return; + } + let r = cf(this.corner, e); + if (r.rowSpan === 1 && r.columnSpan === 1) { + this.stopJoining(); + return; + } + hf(t, r) && (this.stopJoining(), W(this, "regions-merge", { rectangle: r })); + } + onCellDoubleClick(e, t) { + let n = sf(t, e); + n && this.isInJoinedRegion(e, t) && (this.stopJoining(), W(this, "region-split", { regionId: n.id })); + } + onSlotClick(e) { + W(this, "region-select", { regionId: e }); + } + renderCell(e, n, r) { + let i = this.cellAt(n, r), a = this.isInJoin(i); + return F` + + `; + } + renderOutline(e, n, r) { + let i = e.regions.find((e) => e.id === n.id); + return i ? F` +
+ ${ef(r)} + + ${t.basic.regionSize(n.columnSpan, n.rowSpan)} + +
+ ` : I; + } + renderLayoutStep(e, t) { + return F` + ${t.regions.map((t, n) => this.renderOutline(e, t, n))} + ${e.cells.map((e, n) => this.renderCell(e, n, t))} + ${this.renderStatus(t)} + `; + } + statusMessage(e) { + if (this.corner) return this.joining !== void 0 && !this.joinIsValid(e) ? t.basic.joinInvalid : t.basic.joinStarted; + } + renderStatus(e) { + let t = this.statusMessage(e); + return t ? F` +
${t}
+ ` : I; + } + renderSlotContent(e, n) { + let r = Sf(this.dashboard, e); + return r ? e === this.selectedRegionId ? F` + ${r.name} + ` : I : F` + + ${n} + ${t.basic.emptyRegion} + + `; + } + renderSlot(e, n, r) { + let i = !Sf(this.dashboard, n), a = n === this.selectedRegionId; + return F` + + `; + } + renderWidgetsStep(e) { + return F` + ${e.regions.map((e, t) => this.renderSlot(e, e.id, ef(t)))} + `; + } + render() { + let e = this.layout, t = this.frames; + return !e || !t ? I : this.step === "layout" ? this.renderLayoutStep(t, e) : this.renderWidgetsStep(t); + } +}; +Y([z({ attribute: !1 })], Ap.prototype, "dashboard", void 0), Y([z({ attribute: !1 })], Ap.prototype, "frames", void 0), Y([z()], Ap.prototype, "step", void 0), Y([z()], Ap.prototype, "selectedRegionId", void 0), Y([B()], Ap.prototype, "corner", void 0), Y([B()], Ap.prototype, "hover", void 0), Ap = Y([R("ods-region-layer")], Ap); +//#endregion //#region src/ods-zoom-bar.ts -var Vf = [ +var jp = [ .5, 1, 2, 3 -], Hf = class extends L { +], Mp = class extends L { constructor(...e) { super(...e), this.zoom = 1; } @@ -8974,7 +9548,7 @@ var Vf = [ - ${Vf.map((e) => F` + ${jp.map((e) => F` @@ -10011,30 +10599,30 @@ var Uf = 3, Wf = 1, Gf = (e, t) => ({ } addPointAfter(e, t, n) { let r = t[(n + 1) % t.length]; - this.changePoints(e, Lf(t, n, Rf(t[n], r))); + this.changePoints(e, Tp(t, n, Ep(t[n], r))); } onPointHandleDoubleClick(e, t, n) { if (e.stopPropagation(), t.kind !== "primitive" || t.locked) return; - let r = zf(t.primitive); - !r || t.primitive.type !== "polygon" || this.changePoints(t, Pf(r, n)); + let r = Dp(t.primitive); + !r || t.primitive.type !== "polygon" || this.changePoints(t, Sp(r, n)); } onPointHandlePointerDown(e, t, n) { this.startsViewDrag(e) || (e.stopPropagation(), e.preventDefault(), W(this, "item-select", { itemId: t.id }), !(t.kind !== "primitive" || t.locked) && this.beginPointDrag(e, t, n)); } beginPointDrag(e, t, n) { - let r = zf(t.primitive); + let r = Dp(t.primitive); if (!r) return; this.stopGesture?.(); let i = structuredClone(this.dashboard), a = this.watchEscape(i, () => void 0), o = w(this.dashboard.items, t.id)?.offset ?? { x: 0, y: 0 - }, { width: s, height: c } = this.dashboard.display, l = Of({ + }, { width: s, height: c } = this.dashboard.display, l = gp({ origin: e, - threshold: Uf, + threshold: Np, onMove: (i) => { let { dx: a, dy: l } = this.displayDelta(e, i), u = this.snapEnabled && !i.ctrlKey && !i.metaKey, d = (e, t, n) => _(mt(e + t, this.dashboard, u) - t, -n, 2 * n), f = [d(r[n][0] + a, o.x, s), d(r[n][1] + l, o.y, c)], p = { ...t, - primitive: Bf(t.primitive, If(r, n, f)) + primitive: Op(t.primitive, wp(r, n, f)) }; W(this, "items-transform", { items: [p] }); }, @@ -10074,13 +10662,13 @@ var Uf = 3, Wf = 1, Gf = (e, t) => ({ }; } renderPlaced(e) { - let { item: t } = e, n = mf(t, e.offset, this.measure(t)), r = this.selectedItemIds.includes(t.id), i = this.selectedItemIds.length === 1; + let { item: t } = e, n = np(t, e.offset, this.measure(t)), r = this.selectedItemIds.includes(t.id), i = this.selectedItemIds.length === 1; return F`
this.onItemPointerDown(e, t)} @@ -10098,11 +10686,11 @@ var Uf = 3, Wf = 1, Gf = (e, t) => ({ } renderSelectionBox() { if (this.selectedItemIds.length < 2) return I; - let e = Tf(this.dashboard, this.selectedItemIds, this.measure); + let e = pp(this.dashboard, this.selectedItemIds, this.measure); return e ? F`
${this.renderSelectionSize(e)}
@@ -10148,12 +10736,12 @@ var Uf = 3, Wf = 1, Gf = (e, t) => ({ return this.marquee ? F`
` : I; } renderCommandButton(e, t) { - let n = Au(wu(e), t, pd()); + let n = Pu(Ou(e), t, Sd()); return F`
`; } - renderToolbar() { - let e = this.dashboard, { width: n, height: r, padding: i, snapSize: a } = e.display, o = Yd({ - "tool-toggle": !0, - active: this.snapEnabled - }), s = Yd({ + renderPanToggle() { + return F` + + `; + } + renderGridToggle() { + return F` + + `; + } + renderSnapToggle() { + return F` + + `; + } + renderHistoryControls() { + return F` +
+ ${this.renderCommandButton("undo", this.historyContext)} + ${this.renderCommandButton("redo", this.historyContext)} +
+ `; + } + renderZoomReadout() { + return F` + ${Math.round(this.viewport.zoom * 100)}% + `; + } + renderToolbar() { + let e = this.dashboard, { width: n, height: r, padding: i } = e.display; return F`
${t.common.sizeInPixels(n, r)} ${t.canvas.layers(Dt(e.items))} ${t.canvas.padding(i)} -
- ${this.renderCommandButton("undo", this.historyContext)} - ${this.renderCommandButton("redo", this.historyContext)} -
- - - - - ${Math.round(this.viewport.zoom * 100)}% - + ${this.renderHistoryControls()} ${this.renderPanToggle()} + ${this.renderGridToggle()} ${this.renderSnapToggle()} + ${this.renderZoomReadout()} +
+ `; + } + renderBasicToolbar() { + let { width: e, height: n, padding: r } = this.dashboard.display; + return F` +
+ ${t.common.sizeInPixels(e, n)} + ${t.canvas.padding(r)} + ${this.renderHistoryControls()} ${this.renderPanToggle()} + ${this.renderZoomReadout()}
`; } @@ -10293,20 +10913,44 @@ var Uf = 3, Wf = 1, Gf = (e, t) => ({ renderClipFrame(e) { return e === 0 ? I : F` + `; + } + renderRegions() { + return F` + + `; + } + renderItems() { + let e = this.dashboard, t = V({ + ...Fp(pt(e), e.display), + "--snap-size": `${e.display.snapSize * this.viewport.zoom}px` + }); + return F` + + ${zt(e.items).map((e) => this.renderPlaced(e))} + ${this.renderSelectionBox()} ${this.renderMarquee()} + ${this.guides.map((e) => this.renderGuide(e))} + ${this.spacing.map((e) => this.renderSpacing(e))} `; } renderStage() { - let e = this.dashboard, { width: t, height: n, snapSize: r } = e.display, { zoom: i, panX: a, panY: o } = this.viewport, s = V({ transform: `translate(${a}px, ${o}px) scale(${i})` }), c = V({ - width: `${t}px`, - height: `${n}px`, - "--ui": String(1 / i) - }), l = V({ - ...Gf(pt(e), e.display), - "--snap-size": `${r * i}px` + let { width: e, height: t } = this.dashboard.display, { zoom: n, panX: r, panY: i } = this.viewport, a = V({ transform: `translate(${r}px, ${i}px) scale(${n})` }), o = V({ + width: `${e}px`, + height: `${t}px`, + "--ui": String(1 / n) }); return F`
({ @dragover=${this.onStageDragOver} @drop=${this.onStageDrop} > -
+
${this.renderPreview()} - - ${zt(e.items).map((e) => this.renderPlaced(e))} - ${this.renderSelectionBox()} ${this.renderMarquee()} - ${this.guides.map((e) => this.renderGuide(e))} - ${this.spacing.map((e) => this.renderSpacing(e))} + ${this.isBasic ? this.renderRegions() : this.renderItems()}
({ } render() { return F` -
- ${this.renderToolbar()} ${this.renderArrangeBar()} ${this.renderStage()} +
+ ${this.isBasic ? this.renderBasicToolbar() : this.renderToolbar()} + ${this.isBasic ? I : this.renderArrangeBar()} + ${this.renderStage()}
`; } }; -Y([z({ attribute: !1 })], X.prototype, "dashboard", void 0), Y([z({ attribute: !1 })], X.prototype, "preview", void 0), Y([z({ attribute: !1 })], X.prototype, "widgets", void 0), Y([z({ attribute: !1 })], X.prototype, "primitives", void 0), Y([z()], X.prototype, "selectedItemId", void 0), Y([z({ attribute: !1 })], X.prototype, "selectedItemIds", void 0), Y([z()], X.prototype, "enteredGroupId", void 0), Y([z({ type: Boolean })], X.prototype, "snapEnabled", void 0), Y([z()], X.prototype, "alignTo", void 0), Y([z({ type: Boolean })], X.prototype, "acceptingDrop", void 0), Y([z({ type: Boolean })], X.prototype, "canUndo", void 0), Y([z({ type: Boolean })], X.prototype, "canRedo", void 0), Y([z({ attribute: !1 })], X.prototype, "viewport", void 0), Y([bl(".canvas")], X.prototype, "canvas", void 0), Y([bl(".canvas-stage")], X.prototype, "stage", void 0), Y([B()], X.prototype, "dropContainerId", void 0), Y([B()], X.prototype, "marquee", void 0), Y([B()], X.prototype, "guides", void 0), Y([B()], X.prototype, "spacing", void 0), Y([B()], X.prototype, "panMode", void 0), Y([B()], X.prototype, "gridVisible", void 0), Y([B()], X.prototype, "spaceHeld", void 0), X = Y([R("ods-canvas")], X); +Y([z({ attribute: !1 })], X.prototype, "dashboard", void 0), Y([z({ attribute: !1 })], X.prototype, "preview", void 0), Y([z({ attribute: !1 })], X.prototype, "widgets", void 0), Y([z({ attribute: !1 })], X.prototype, "primitives", void 0), Y([z()], X.prototype, "selectedItemId", void 0), Y([z({ attribute: !1 })], X.prototype, "selectedItemIds", void 0), Y([z()], X.prototype, "enteredGroupId", void 0), Y([z({ type: Boolean })], X.prototype, "snapEnabled", void 0), Y([z()], X.prototype, "alignTo", void 0), Y([z({ type: Boolean })], X.prototype, "acceptingDrop", void 0), Y([z({ type: Boolean })], X.prototype, "canUndo", void 0), Y([z({ type: Boolean })], X.prototype, "canRedo", void 0), Y([z({ attribute: !1 })], X.prototype, "layoutFrames", void 0), Y([z()], X.prototype, "basicStep", void 0), Y([z()], X.prototype, "selectedRegionId", void 0), Y([z({ attribute: !1 })], X.prototype, "viewport", void 0), Y([Sl(".canvas")], X.prototype, "canvas", void 0), Y([Sl(".canvas-stage")], X.prototype, "stage", void 0), Y([B()], X.prototype, "dropContainerId", void 0), Y([B()], X.prototype, "marquee", void 0), Y([B()], X.prototype, "guides", void 0), Y([B()], X.prototype, "spacing", void 0), Y([B()], X.prototype, "panMode", void 0), Y([B()], X.prototype, "gridVisible", void 0), Y([B()], X.prototype, "spaceHeld", void 0), X = Y([R("ods-canvas")], X); //#endregion //#region src/ods-code-view.ts -var Yf = { +var zp = { idle: "mdi:content-copy", copied: "mdi:check", failed: "mdi:alert-circle-outline" -}, Xf = 2200, Zf = class extends L { +}, Bp = 2200, Vp = class extends L { constructor(...e) { super(...e), this.copyState = "idle"; } @@ -10463,7 +11101,7 @@ var Yf = { } this.copyTimer = window.setTimeout(() => { this.copyState = "idle"; - }, Xf); + }, Bp); } } render() { @@ -10484,7 +11122,7 @@ var Yf = { > ${t.code.copy[this.copyState]} @@ -10507,10 +11145,10 @@ var Yf = { `; } }; -Y([z({ attribute: !1 })], Zf.prototype, "preview", void 0), Y([B()], Zf.prototype, "copyState", void 0), Zf = Y([R("ods-code-view")], Zf); +Y([z({ attribute: !1 })], Vp.prototype, "preview", void 0), Y([B()], Vp.prototype, "copyState", void 0), Vp = Y([R("ods-code-view")], Vp); //#endregion //#region src/ods-confirm-dialog.ts -var Qf = class extends L { +var Hp = class extends L { constructor(...e) { super(...e), this.eyebrow = "", this.heading = "", this.body = "", this.confirmLabel = ""; } @@ -10616,26 +11254,26 @@ var Qf = class extends L { `; } }; -Y([z()], Qf.prototype, "eyebrow", void 0), Y([z()], Qf.prototype, "heading", void 0), Y([z()], Qf.prototype, "body", void 0), Y([z()], Qf.prototype, "confirmLabel", void 0), Qf = Y([R("ods-confirm-dialog")], Qf); +Y([z()], Hp.prototype, "eyebrow", void 0), Y([z()], Hp.prototype, "heading", void 0), Y([z()], Hp.prototype, "body", void 0), Y([z()], Hp.prototype, "confirmLabel", void 0), Hp = Y([R("ods-confirm-dialog")], Hp); //#endregion //#region src/series.ts -var $f = "entity", ep = (e) => e.shape === "objects" && (e.nested ?? []).some((e) => e.shape === "entity"), tp = (e) => typeof e == "object" && !!e && !Array.isArray(e), np = (e) => Array.isArray(e) ? e.filter(tp) : [], rp = (e, t = "") => { +var Up = "entity", Wp = (e) => e.shape === "objects" && (e.nested ?? []).some((e) => e.shape === "entity"), Gp = (e) => typeof e == "object" && !!e && !Array.isArray(e), Kp = (e) => Array.isArray(e) ? e.filter(Gp) : [], qp = (e, t = "") => { let n = (e.nested ?? []).flatMap((e) => e.default === void 0 ? [] : [[e.key, e.default]]); return { ...Object.fromEntries(n), - [$f]: t + [Up]: t }; -}, ip = (e, t) => t.length < (typeof e.max == "number" ? e.max : t.length + 1), ap = (e, t) => t.length > (typeof e.min == "number" ? e.min : 1), op = (e, t, n) => e.map((e, r) => r === t ? { +}, Jp = (e, t) => t.length < (typeof e.max == "number" ? e.max : t.length + 1), Yp = (e, t) => t.length > (typeof e.min == "number" ? e.min : 1), Xp = (e, t, n) => e.map((e, r) => r === t ? { ...e, ...n -} : e), sp = (e, t) => e.filter((e, n) => n !== t), cp = class extends L { +} : e), Zp = (e, t) => e.filter((e, n) => n !== t), Qp = class extends L { constructor(...e) { super(...e), this.label = "", this.fieldKey = "", this.unit = "", this.placeholder = "", this.value = 0, this.min = 0, this.max = 4096, this.disabled = !1; } static { this.styles = [ G, - Jd, + Lf, P` :host { display: block; @@ -10728,7 +11366,7 @@ var $f = "entity", ep = (e) => e.shape === "objects" && (e.nested ?? []).some((e onChange(e) { W(this, "field-change", { key: this.fieldKey, - value: dd(e) + value: bd(e) }); } render() { @@ -10755,10 +11393,10 @@ var $f = "entity", ep = (e) => e.shape === "objects" && (e.nested ?? []).some((e `; } }; -Y([z()], cp.prototype, "label", void 0), Y([z()], cp.prototype, "fieldKey", void 0), Y([z()], cp.prototype, "unit", void 0), Y([z()], cp.prototype, "placeholder", void 0), Y([z({ attribute: !1 })], cp.prototype, "value", void 0), Y([z({ type: Number })], cp.prototype, "min", void 0), Y([z({ type: Number })], cp.prototype, "max", void 0), Y([z({ type: Boolean })], cp.prototype, "disabled", void 0), Y([bl("input")], cp.prototype, "input", void 0), cp = Y([R("ods-property-field")], cp); +Y([z()], Qp.prototype, "label", void 0), Y([z()], Qp.prototype, "fieldKey", void 0), Y([z()], Qp.prototype, "unit", void 0), Y([z()], Qp.prototype, "placeholder", void 0), Y([z({ attribute: !1 })], Qp.prototype, "value", void 0), Y([z({ type: Number })], Qp.prototype, "min", void 0), Y([z({ type: Number })], Qp.prototype, "max", void 0), Y([z({ type: Boolean })], Qp.prototype, "disabled", void 0), Y([Sl("input")], Qp.prototype, "input", void 0), Qp = Y([R("ods-property-field")], Qp); //#endregion //#region src/ods-anchor-picker.ts -var lp = [ +var $p = [ "lt", "mt", "rt", @@ -10768,7 +11406,7 @@ var lp = [ "lb", "mb", "rb" -], up = (e) => lp.some((t) => t === e), dp = class extends L { +], em = (e) => $p.some((t) => t === e), tm = class extends L { constructor(...e) { super(...e), this.value = "", this.disabled = !1; } @@ -10837,15 +11475,15 @@ var lp = [ render() { return F`
- ${lp.map((e) => this.renderPosition(e))} + ${$p.map((e) => this.renderPosition(e))}
`; } }; -Y([z()], dp.prototype, "value", void 0), Y([z({ type: Boolean })], dp.prototype, "disabled", void 0), dp = Y([R("ods-anchor-picker")], dp); +Y([z()], tm.prototype, "value", void 0), Y([z({ type: Boolean })], tm.prototype, "disabled", void 0), tm = Y([R("ods-anchor-picker")], tm); //#endregion //#region src/ods-color-picker.ts -var fp = class extends L { +var nm = class extends L { constructor(...e) { super(...e), this.palette = "bw", this.value = "", this.nullable = !1; } @@ -10932,10 +11570,10 @@ var fp = class extends L { `; } }; -Y([z()], fp.prototype, "palette", void 0), Y([z()], fp.prototype, "value", void 0), Y([z({ type: Boolean })], fp.prototype, "nullable", void 0), fp = Y([R("ods-color-picker")], fp); +Y([z()], nm.prototype, "palette", void 0), Y([z()], nm.prototype, "value", void 0), Y([z({ type: Boolean })], nm.prototype, "nullable", void 0), nm = Y([R("ods-color-picker")], nm); //#endregion //#region src/font-faces.ts -var pp = "/api/opendisplay_studio/fonts", mp = "/api/opendisplay_studio/icon-font", hp = "ods-mdi", gp = /* @__PURE__ */ new Map(), _p = (e) => `ods-font-${e.name.replace(/[^A-Za-z0-9_-]/g, "_")}-${e.version}`, vp = (e) => `${pp}/${encodeURIComponent(e.name)}?v=${e.version}`, yp = async (e, t, n) => { +var rm = "/api/opendisplay_studio/fonts", im = "/api/opendisplay_studio/icon-font", am = "ods-mdi", om = /* @__PURE__ */ new Map(), sm = (e) => `ods-font-${e.name.replace(/[^A-Za-z0-9_-]/g, "_")}-${e.version}`, cm = (e) => `${rm}/${encodeURIComponent(e.name)}?v=${e.version}`, lm = async (e, t, n) => { if (e.fetchWithAuth) try { let r = await e.fetchWithAuth(n); if (!r.ok) return; @@ -10944,32 +11582,32 @@ var pp = "/api/opendisplay_studio/fonts", mp = "/api/opendisplay_studio/icon-fon } catch { return; } -}, bp = (e, t, n) => { - let r = gp.get(t); - return r || (r = yp(e, t, n), gp.set(t, r)), r; -}, xp = (e, t) => bp(e, _p(t), vp(t)), Sp = (e) => bp(e, hp, mp), Cp = "mdi:", wp = 36, Tp = 288, Ep = 6, Dp = (e) => e.startsWith(Cp) ? e.slice(4) : e, Op = (e, t) => { +}, um = (e, t, n) => { + let r = om.get(t); + return r || (r = lm(e, t, n), om.set(t, r)), r; +}, dm = (e, t) => um(e, sm(t), cm(t)), fm = (e) => um(e, am, im), pm = "mdi:", mm = 36, hm = 288, gm = 6, _m = (e) => e.startsWith(pm) ? e.slice(4) : e, vm = (e, t) => { let n = t.trim().toLowerCase().split(/\s+/).filter(Boolean); if (n.length === 0) return e; let r = e.filter((e) => n.every((t) => e.includes(t))), i = n[0], a = r.filter((e) => e.startsWith(i)), o = r.filter((e) => !e.startsWith(i)); return [...a, ...o]; -}, kp = (e, t) => ({ - first: Math.max(0, Math.floor(e / wp) - Ep), - last: Math.min(t, Math.ceil((e + Tp) / wp) + Ep) -}), Ap = class extends L { +}, ym = (e, t) => ({ + first: Math.max(0, Math.floor(e / mm) - gm), + last: Math.min(t, Math.ceil((e + hm) / mm) + gm) +}), bm = class extends L { constructor(...e) { super(...e), this.icons = [], this.glyphs = {}, this.value = "", this.iconFontReady = !1, this.search = "", this.listScroll = 0; } static { this.styles = [ G, - Jd, + Lf, P` :host { display: block; } .list { position: relative; - height: ${Tp}px; + height: ${hm}px; margin-top: 8px; overflow: auto; overscroll-behavior: contain; @@ -10984,7 +11622,7 @@ var pp = "/api/opendisplay_studio/fonts", mp = "/api/opendisplay_studio/icon-fon align-items: center; gap: 10px; width: 100%; - height: ${wp}px; + height: ${mm}px; padding: 0 8px; border: 1px solid transparent; border-radius: 7px; @@ -11016,7 +11654,7 @@ var pp = "/api/opendisplay_studio/fonts", mp = "/api/opendisplay_studio/icon-fon .glyph { flex: none; width: 22px; - font-family: "${mc(hp)}"; + font-family: "${mc(am)}"; font-size: 22px; line-height: 22px; text-align: center; @@ -11033,7 +11671,7 @@ var pp = "/api/opendisplay_studio/fonts", mp = "/api/opendisplay_studio/icon-fon e.has("hass") && this.hass && !this.iconFontReady && this.loadGlyphs(this.hass); } async loadGlyphs(e) { - this.iconFontReady = await Sp(e) !== void 0; + this.iconFontReady = await fm(e) !== void 0; } onSearch(e) { e.target instanceof HTMLInputElement && (this.search = e.target.value, this.listScroll = 0, this.list?.scrollTo({ top: 0 })); @@ -11049,7 +11687,7 @@ var pp = "/api/opendisplay_studio/fonts", mp = "/api/opendisplay_studio/icon-fon return this.iconFontReady && t ? F` ` : F` - + `; } renderIcon(e) { @@ -11058,7 +11696,7 @@ var pp = "/api/opendisplay_studio/fonts", mp = "/api/opendisplay_studio/icon-fon type="button" title=${e} aria-label=${e} - aria-pressed=${e === Dp(this.value) ? "true" : "false"} + aria-pressed=${e === _m(this.value) ? "true" : "false"} @click=${() => this.choose(e)} > ${this.renderPicture(e)} @@ -11067,17 +11705,17 @@ var pp = "/api/opendisplay_studio/fonts", mp = "/api/opendisplay_studio/icon-fon `; } renderResults() { - let e = Op(this.icons, this.search); + let e = vm(this.icons, this.search); if (e.length === 0) return F`

${t.iconPicker.noMatch}

`; - let { first: n, last: r } = kp(this.listScroll, e.length); + let { first: n, last: r } = ym(this.listScroll, e.length); return F`
-
+
${e.slice(n, r).map((e) => this.renderIcon(e))}
@@ -11100,10 +11738,10 @@ var pp = "/api/opendisplay_studio/fonts", mp = "/api/opendisplay_studio/icon-fon `; } }; -Y([z({ attribute: !1 })], Ap.prototype, "hass", void 0), Y([z({ attribute: !1 })], Ap.prototype, "icons", void 0), Y([z({ attribute: !1 })], Ap.prototype, "glyphs", void 0), Y([z()], Ap.prototype, "value", void 0), Y([B()], Ap.prototype, "iconFontReady", void 0), Y([B()], Ap.prototype, "search", void 0), Y([B()], Ap.prototype, "listScroll", void 0), Y([bl(".list")], Ap.prototype, "list", void 0), Ap = Y([R("ods-icon-picker")], Ap); +Y([z({ attribute: !1 })], bm.prototype, "hass", void 0), Y([z({ attribute: !1 })], bm.prototype, "icons", void 0), Y([z({ attribute: !1 })], bm.prototype, "glyphs", void 0), Y([z()], bm.prototype, "value", void 0), Y([B()], bm.prototype, "iconFontReady", void 0), Y([B()], bm.prototype, "search", void 0), Y([B()], bm.prototype, "listScroll", void 0), Y([Sl(".list")], bm.prototype, "list", void 0), bm = Y([R("ods-icon-picker")], bm); //#endregion //#region src/ods-popover.ts -var jp = 6, Mp = 8, Np = class extends L { +var xm = 6, Sm = 8, Cm = class extends L { constructor(...e) { super(...e), this.heading = "", this.width = 220, this.resizeObserver = new ResizeObserver(() => this.place()), this.onOutsidePointer = (e) => { e.composedPath().includes(this) || this.close(); @@ -11178,8 +11816,8 @@ var jp = 6, Mp = 8, Np = class extends L { place() { let e = this.panel; if (!e || !this.anchor) return; - let t = e.offsetHeight, n = this.anchor.bottom + jp, r = n + t + Mp <= window.innerHeight ? n : this.anchor.top - jp - t, i = Math.min(this.width, window.innerWidth - 16), a = Math.min(Math.max(Mp, this.anchor.left), window.innerWidth - i - Mp); - e.style.top = `${Math.max(Mp, r)}px`, e.style.left = `${a}px`, e.style.width = `${i}px`; + let t = e.offsetHeight, n = this.anchor.bottom + xm, r = n + t + Sm <= window.innerHeight ? n : this.anchor.top - xm - t, i = Math.min(this.width, window.innerWidth - 16), a = Math.min(Math.max(Sm, this.anchor.left), window.innerWidth - i - Sm); + e.style.top = `${Math.max(Sm, r)}px`, e.style.left = `${a}px`, e.style.width = `${i}px`; } render() { return this.anchor ? F` @@ -11205,17 +11843,17 @@ var jp = 6, Mp = 8, Np = class extends L { ` : I; } }; -Y([z()], Np.prototype, "heading", void 0), Y([z({ attribute: !1 })], Np.prototype, "anchor", void 0), Y([z({ type: Number })], Np.prototype, "width", void 0), Y([bl(".popover")], Np.prototype, "panel", void 0), Np = Y([R("ods-popover")], Np); +Y([z()], Cm.prototype, "heading", void 0), Y([z({ attribute: !1 })], Cm.prototype, "anchor", void 0), Y([z({ type: Number })], Cm.prototype, "width", void 0), Y([Sl(".popover")], Cm.prototype, "panel", void 0), Cm = Y([R("ods-popover")], Cm); //#endregion //#region src/ods-icon-field.ts -var Pp = "mdi:", Fp = 250, Ip = -1, Lp = class extends L { +var wm = "mdi:", Tm = 250, Em = -1, Dm = class extends L { constructor(...e) { super(...e), this.disabled = !1, this.names = [], this.glyphs = {}, this.closedAt = 0; } static { this.styles = [ G, - Jd, + Lf, P` :host { display: block; @@ -11304,14 +11942,14 @@ var Pp = "mdi:", Fp = 250, Ip = -1, Lp = class extends L { } async loadNames() { if (!(this.names.length > 0 || !this.hass)) try { - let e = await Pd(this.hass); + let e = await Wd(this.hass); this.names = e.names, this.glyphs = e.glyphs; } catch { this.names = []; } } openAt(e, t) { - if (this.disabled || Date.now() - this.closedAt < Fp) return; + if (this.disabled || Date.now() - this.closedAt < Tm) return; let n = t.currentTarget; n instanceof HTMLElement && (this.open = { index: e, @@ -11322,14 +11960,14 @@ var Pp = "mdi:", Fp = 250, Ip = -1, Lp = class extends L { this.openAt(0, e); } openNew(e) { - this.openAt(Ip, e); + this.openAt(Em, e); } close() { this.closedAt = Date.now(), this.open = void 0; } onIconChange(e) { - let t = Dp(e.detail.icon), n = this.open?.index ?? 0; - this.isList ? n === Ip ? this.change([...this.icons, t]) : this.change(this.icons.map((e, r) => r === n ? t : e)) : this.change(t), this.close(); + let t = _m(e.detail.icon), n = this.open?.index ?? 0; + this.isList ? n === Em ? this.change([...this.icons, t]) : this.change(this.icons.map((e, r) => r === n ? t : e)) : this.change(t), this.close(); } removeAt(e) { let t = this.icons.filter((t, n) => n !== e); @@ -11359,7 +11997,7 @@ var Pp = "mdi:", Fp = 250, Ip = -1, Lp = class extends L { .disabled=${this.disabled} @click=${(e) => this.openAt(n, e)} > - + @@ -11443,17 +12081,17 @@ var Pp = "mdi:", Fp = 250, Ip = -1, Lp = class extends L { `; } }; -Y([z({ attribute: !1 })], Lp.prototype, "hass", void 0), Y([z({ attribute: !1 })], Lp.prototype, "field", void 0), Y([z({ attribute: !1 })], Lp.prototype, "value", void 0), Y([z({ type: Boolean })], Lp.prototype, "disabled", void 0), Y([B()], Lp.prototype, "open", void 0), Y([B()], Lp.prototype, "names", void 0), Y([B()], Lp.prototype, "glyphs", void 0), Lp = Y([R("ods-icon-field")], Lp); +Y([z({ attribute: !1 })], Dm.prototype, "hass", void 0), Y([z({ attribute: !1 })], Dm.prototype, "field", void 0), Y([z({ attribute: !1 })], Dm.prototype, "value", void 0), Y([z({ type: Boolean })], Dm.prototype, "disabled", void 0), Y([B()], Dm.prototype, "open", void 0), Y([B()], Dm.prototype, "names", void 0), Y([B()], Dm.prototype, "glyphs", void 0), Dm = Y([R("ods-icon-field")], Dm); //#endregion //#region src/ods-points-field.ts -var Rp = class extends L { +var Om = class extends L { constructor(...e) { super(...e), this.disabled = !1; } static { this.styles = [ G, - Jd, + Lf, P` :host { display: block; @@ -11505,7 +12143,7 @@ var Rp = class extends L { ]; } get points() { - return Af(this.value); + return vp(this.value); } change(e) { W(this, "primitive-field-change", { @@ -11520,7 +12158,7 @@ var Rp = class extends L { n.target.value = String(this.points[e][t]); return; } - this.change(Ff(this.points, e, t, r)); + this.change(Cp(this.points, e, t, r)); } onXChange(e, t) { this.onCoordinateChange(e, 0, t); @@ -11529,10 +12167,10 @@ var Rp = class extends L { this.onCoordinateChange(e, 1, t); } removeAt(e) { - this.change(Pf(this.points, e)); + this.change(Sp(this.points, e)); } add() { - this.change(Nf(this.points)); + this.change(xp(this.points)); } renderCoordinate(e, t, n) { return F` @@ -11559,7 +12197,7 @@ var Rp = class extends L { type="button" title=${r} aria-label=${r} - .disabled=${this.disabled || !Mf(this.points)} + .disabled=${this.disabled || !bp(this.points)} @click=${() => this.removeAt(n)} > @@ -11576,7 +12214,7 @@ var Rp = class extends L { ${this.renderPicker()} `; } }; -Y([z({ attribute: !1 })], fm.prototype, "hass", void 0), Y([z({ attribute: !1 })], fm.prototype, "field", void 0), Y([z()], fm.prototype, "value", void 0), Y([z({ type: Boolean })], fm.prototype, "disabled", void 0), Y([B()], fm.prototype, "anchor", void 0), fm = Y([R("ods-image-field")], fm); +Y([z({ attribute: !1 })], nh.prototype, "hass", void 0), Y([z({ attribute: !1 })], nh.prototype, "field", void 0), Y([z()], nh.prototype, "value", void 0), Y([z({ type: Boolean })], nh.prototype, "disabled", void 0), Y([B()], nh.prototype, "anchor", void 0), nh = Y([R("ods-image-field")], nh); //#endregion //#region src/ods-value-field.ts -var pm = 250, mm = 4, hm = "lt", gm = class extends L { +var rh = 250, ih = 4, ah = "lt", oh = class extends L { constructor(...e) { super(...e), this.palette = "bw", this.display = { width: 800, @@ -12328,7 +12966,7 @@ var pm = 250, mm = 4, hm = "lt", gm = class extends L { static { this.styles = [ G, - Jd, + Lf, P` :host { display: block; @@ -12433,7 +13071,7 @@ var pm = 250, mm = 4, hm = "lt", gm = class extends L { }); } openPicker(e, t) { - if (this.disabled || Date.now() - this.closedAt < pm) return; + if (this.disabled || Date.now() - this.closedAt < rh) return; let n = t.currentTarget; n instanceof HTMLElement && (this.picker = { kind: e, @@ -12558,14 +13196,14 @@ var pm = 250, mm = 4, hm = "lt", gm = class extends L { renderAnchorMark(e) { return F` - ${lp.map((t) => F` + ${$p.map((t) => F` `)} `; } renderAnchor() { - let e = String(this.value ?? "") || hm, n = up(e) ? t.anchors[e] : e; + let e = String(this.value ?? "") || ah, n = em(e) ? t.anchors[e] : e; return F` - ${ym.map((e) => this.renderGroup(e))} + ${lh.map((e) => this.renderGroup(e))}
`; } }; -bm = Y([R("ods-shortcuts-dialog")], bm); +uh = Y([R("ods-shortcuts-dialog")], uh); //#endregion -//#region src/ods-dashboard-card.ts -var xm = () => [ - { - id: "rename", - label: t.gallery.menu.rename, +//#region src/ods-basic-steps.ts +var dh = class extends L { + constructor(...e) { + super(...e), this.step = "layout"; + } + static { + this.styles = [ + G, + K, + Lf, + P` + :host { + display: block; + padding: var(--ha-space-2, 8px) 9px; + border-inline-end: 1px solid var(--studio-border); + border-bottom: 1px solid var(--studio-border); + background: var(--studio-surface); + } + .segmented { + height: 32px; + } + .segmented button { + font-size: 12px; + } + .segmented button[aria-pressed="true"] { + background: var(--studio-accent); + } + ` + ]; + } + renderStep(e, t) { + return F` + + `; + } + render() { + return F` +
+ ${this.renderStep("layout", t.basic.stepLayout)} + ${this.renderStep("widgets", t.basic.stepWidgets)} +
+ `; + } +}; +Y([z()], dh.prototype, "step", void 0), dh = Y([R("ods-basic-steps")], dh); +//#endregion +//#region src/display-shape.ts +var fh = .03, ph = 2.5, mh = 5, hh = (e, t, n) => Math.min(n, Math.max(t, e)), gh = (e) => e === 90 || e === 270, _h = (e, t) => gh(t) ? { + width: e.height, + height: e.width +} : e, vh = (e, t) => { + let n = _h(e, e.rotation), r = hh(Math.min(t.width, t.height) * fh, ph, mh), i = Math.min((t.width - 2 * r) / n.width, (t.height - 2 * r) / n.height), a = { + width: n.width * i, + height: n.height * i + }; + return { + housing: { + width: a.width + 2 * r, + height: a.height + 2 * r + }, + screen: a, + bezel: r + }; +}, yh = (e) => e * Math.PI / 180, bh = (e, t) => { + let n = Math.abs(Math.cos(yh(t))), r = Math.abs(Math.sin(yh(t))); + return { + width: e.width * n + e.height * r, + height: e.width * r + e.height * n + }; +}, xh = (e, t, n) => { + let r = bh(e, t); + return Math.min(1, n.width / r.width, n.height / r.height); +}, Sh = (e, t) => { + let n = Math.abs(Math.cos(yh(t))), r = Math.abs(Math.sin(yh(t))), i = bh(e, t), a = i.width / i.height, o = Math.min(e.width / (n + r / a), e.height / (r + n / a)); + return { + width: o, + height: o / a + }; +}, Ch = 6, wh = 9, Th = .13, Eh = .76, Dh = (e) => Math.sin(Math.PI / 2 * e), Oh = (e) => 1 - (1 - e) ** 3, kh = (e) => Math.min(1, Math.max(0, e)), Ah = (e, t, n) => { + let r = Math.sign(t - e); + if (r === 0) return t; + let i = kh(n); + if (i >= 1) return t; + if (i < Th) { + let t = Dh(i / Th); + return e - r * Ch * t; + } + if (i < Eh) { + let n = Oh((i - Th) / .63); + return e - r * Ch + (t - e + r * 15) * n; + } + let a = Oh((i - Eh) / .24); + return t + r * wh * (1 - a); +}, jh = (e, t) => { + let n = (t - (e % 360 + 360) % 360 + 540) % 360 - 180; + return n === -180 ? 180 : n; +}, Mh = { + width: 240, + height: 92 +}, Nh = class extends L { + constructor(...e) { + super(...e), this.width = 800, this.height = 480, this.rotation = 0, this.background = "white", this.accent = "#202124", this.box = Mh, this.turns = !1, this.angle = 0, this.target = 0, this.frame = 0; + } + static { + this.styles = P` + :host { + display: block; + } + svg { + display: block; + width: 100%; + height: 100%; + } + .housing { + fill: #35383b; + } + .screen { + stroke: color-mix(in srgb, var(--studio-text, #202124) 30%, transparent); + stroke-width: 0.5; + } + .light { + fill: #4fd1a5; + } + .button { + fill: #8a9096; + } + .skeleton-title { + fill: color-mix(in srgb, var(--studio-muted, #68727a) 50%, transparent); + } + .skeleton-line { + fill: color-mix(in srgb, var(--studio-muted, #68727a) 28%, transparent); + } + .skeleton-block { + fill: color-mix( + in srgb, + var(--display-accent, #202124) 18%, + var(--studio-surface, #fff) + ); + } + .skeleton-accent { + fill: var(--display-accent, #202124); + } + `; + } + connectedCallback() { + super.connectedCallback(), this.angle = this.rotation, this.target = this.rotation; + } + disconnectedCallback() { + super.disconnectedCallback(), cancelAnimationFrame(this.frame); + } + willUpdate(e) { + e.has("rotation") && this.hasUpdated && this.turnTo(this.rotation); + } + turnTo(e) { + cancelAnimationFrame(this.frame); + let t = this.angle; + if (this.target += jh(this.target, e), !this.turns || Ph()) { + this.angle = this.target; + return; + } + let n = this.target, r = performance.now(), i = (e) => { + let a = (e - r) / 860; + this.angle = Ah(t, n, a), a < 1 && (this.frame = requestAnimationFrame(i)); + }; + this.frame = requestAnimationFrame(i); + } + renderSkeleton(e) { + let t = -e.width / 2, n = -e.height / 2, r = Math.max(3, e.height * .04), i = n + e.height * .25; + return el` + + + + + `; + } + renderHousing(e) { + let { housing: t, screen: n, bezel: r } = e; + return el` + + + + + `; + } + render() { + let e = this.box, n = vh({ + width: this.width, + height: this.height, + rotation: this.rotation + }, e), r = xh(n.housing, this.angle, e), i = Sh(n.screen, this.angle), a = `${t.common.sizeInPixels(this.width, this.height)} · ${this.rotation}°`, o = `translate(${`${e.width / 2} ${e.height / 2}`}) rotate(${this.angle}) scale(${r})`; + return F` + + + ${this.renderHousing(n)} + ${this.renderSkeleton(i)} + + + `; + } +}; +Y([z({ type: Number })], Nh.prototype, "width", void 0), Y([z({ type: Number })], Nh.prototype, "height", void 0), Y([z({ type: Number })], Nh.prototype, "rotation", void 0), Y([z()], Nh.prototype, "background", void 0), Y([z()], Nh.prototype, "accent", void 0), Y([z({ attribute: !1 })], Nh.prototype, "box", void 0), Y([z({ type: Boolean })], Nh.prototype, "turns", void 0), Y([B()], Nh.prototype, "angle", void 0), Nh = Y([R("ods-display-preview")], Nh); +var Ph = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches, Fh = { + width: 240, + height: 92 +}, Ih = () => [ + { + id: "rename", + label: t.gallery.menu.rename, icon: "mdi:pencil-outline" }, { @@ -13240,7 +14149,7 @@ var xm = () => [ { id: "settings", label: t.gallery.menu.settings, - icon: "mdi:monitor-cog" + icon: "mdi:cog-outline" }, { id: "delete", @@ -13248,7 +14157,7 @@ var xm = () => [ icon: "mdi:delete-outline", danger: !0 } -], Sm = class extends L { +], Lh = class extends L { constructor(...e) { super(...e), this.language = "en", this.menuOpen = !1, this.renaming = !1, this.draftName = ""; } @@ -13256,7 +14165,7 @@ var xm = () => [ this.styles = [ G, K, - qd, + If, P` :host { display: contents; @@ -13287,7 +14196,7 @@ var xm = () => [ display: grid; place-items: center; overflow: hidden; - padding: 23px; + padding: 35px 23px 23px; border-radius: 12px 12px 0 0; background: color-mix( in srgb, @@ -13296,54 +14205,6 @@ var xm = () => [ ); pointer-events: none; } - .dashboard-miniature { - position: relative; - width: min(145px, 70%); - max-height: 96px; - overflow: hidden; - border: 2px solid - color-mix(in srgb, var(--dashboard-accent) 22%, var(--studio-border)); - border-radius: 9px; - box-shadow: 0 7px 18px rgba(0, 0, 0, 0.12); - } - .dashboard-miniature > span { - position: absolute; - display: block; - border-radius: 99px; - } - .miniature-title { - left: 12%; - top: 25%; - width: 25%; - height: 4%; - min-height: 3px; - background: color-mix(in srgb, var(--studio-muted) 50%, transparent); - } - .miniature-accent { - right: 12%; - top: 25%; - width: 5px; - height: 5px; - background: var(--dashboard-accent); - } - .miniature-line { - left: 12%; - bottom: 26%; - width: 48%; - height: 4%; - min-height: 3px; - background: color-mix(in srgb, var(--studio-muted) 28%, transparent); - } - .miniature-line.long { - bottom: 39%; - width: 72%; - height: 13%; - background: color-mix( - in srgb, - var(--dashboard-accent) 18%, - var(--studio-surface) - ); - } .dashboard-resolution { position: absolute; right: 11px; @@ -13392,22 +14253,26 @@ var xm = () => [ font-weight: 700; pointer-events: auto; } - .dashboard-card-meta { - display: flex; - align-items: center; - gap: 7px; + .dashboard-card-copy small { color: var(--studio-muted); font-size: 10px; } - .dashboard-card-meta > span + span::before { - content: "·"; - margin-right: 7px; - } - .dashboard-card-copy small { - color: var(--studio-muted); + .mode-badge { + position: absolute; + top: var(--ha-space-2, 8px); + inset-inline-start: var(--ha-space-2, 8px); + padding: 1px 7px; + border: 1px solid var(--studio-border); + border-radius: 999px; + background: var(--studio-surface); + color: var(--studio-accent); font-size: 10px; + font-weight: 600; } .palette-dots { + position: absolute; + inset-inline-start: 11px; + bottom: var(--ha-space-2, 8px); display: inline-flex; align-items: center; gap: 2px; @@ -13486,14 +14351,14 @@ var xm = () => [ e.stopPropagation(), W(this, "dashboard-menu-toggle", { dashboardId: this.dashboard.id }); } onMenuSelect(e) { - let t = xm().find((t) => t.id === e.detail.id); + let t = Ih().find((t) => t.id === e.detail.id); t && (e.stopPropagation(), W(this, "dashboard-menu-action", { dashboard: this.dashboard, action: t.id })); } onRenameInput(e) { - W(this, "dashboard-rename-input", { name: dd(e) }); + W(this, "dashboard-rename-input", { name: bd(e) }); } onRenameKeyDown(e) { e.stopPropagation(), e.key === "Enter" ? (e.preventDefault(), W(this, "dashboard-rename-commit")) : e.key === "Escape" && (e.preventDefault(), W(this, "dashboard-rename-cancel")); @@ -13505,19 +14370,17 @@ var xm = () => [ let { display: e } = this.dashboard; return F`
-
- - - - -
- - ${t.common.size(e.width, e.height)} - + ${this.modeLabel()} + + ${this.renderPaletteDots()} + ${this.resolutionLabel()}
`; } @@ -13543,20 +14406,25 @@ var xm = () => [ `; } - renderMeta() { - let { display: e } = this.dashboard; + resolutionLabel() { + let { width: e, height: n, rotation: r } = this.dashboard.display, i = t.common.size(e, n); + return r === 0 ? i : `${i} · ${r}°`; + } + modeLabel() { + return this.dashboard.mode === "basic" ? t.basic.badgeBasic : t.basic.badgeAdvanced; + } + renderPaletteDots() { + let { palette: e } = this.dashboard.display; return F` - - ${t.common.size(e.width, e.height)} - - ${xo[e.palette].map((e) => F` - - `)} - - ${bo[e.palette]} + + ${xo[e].map((e) => F` + + `)} `; } @@ -13566,7 +14434,7 @@ var xm = () => [ class="dashboard-menu" id=${this.menuId} label=${t.gallery.menuFor(this.dashboard.name)} - .entries=${xm()} + .entries=${Ih()} @menu-select=${this.onMenuSelect} > ` : I; @@ -13574,15 +14442,15 @@ var xm = () => [ render() { let e = this.dashboard; return F` -
${this.renderMiniature()}
- ${this.renderTitle()} ${this.renderMeta()} + ${this.renderTitle()} - ${t.gallery.updated(ud(e, this.language))} + ${t.gallery.updated(yd(e, this.language))}
@@ -13908,7 +14777,7 @@ var Cm = class extends L { W(this, "dashboard-settings-save")} > ${this.saving ? t.gallery.saving : t.gallery.settingsSave} @@ -13918,7 +14787,7 @@ var Cm = class extends L { `; } render() { - let e = Ju(this.dashboards, this.searchText, this.sort, this.language); + let e = $u(this.dashboards, this.searchText, this.sort, this.language); return F`
@@ -14006,17 +14875,142 @@ var Cm = class extends L { `; } }; -Y([z({ attribute: !1 })], Cm.prototype, "dashboards", void 0), Y([z({ attribute: !1 })], Cm.prototype, "hass", void 0), Y([z()], Cm.prototype, "error", void 0), Y([z({ type: Boolean })], Cm.prototype, "saving", void 0), Y([z()], Cm.prototype, "dialog", void 0), Y([z({ attribute: !1 })], Cm.prototype, "draft", void 0), Y([B()], Cm.prototype, "searchText", void 0), Y([B()], Cm.prototype, "sort", void 0), Y([B()], Cm.prototype, "menuDashboardId", void 0), Cm = Y([R("ods-gallery")], Cm); +Y([z({ attribute: !1 })], Rh.prototype, "dashboards", void 0), Y([z({ attribute: !1 })], Rh.prototype, "hass", void 0), Y([z()], Rh.prototype, "error", void 0), Y([z({ type: Boolean })], Rh.prototype, "saving", void 0), Y([z()], Rh.prototype, "dialog", void 0), Y([z({ attribute: !1 })], Rh.prototype, "draft", void 0), Y([B()], Rh.prototype, "searchText", void 0), Y([B()], Rh.prototype, "sort", void 0), Y([B()], Rh.prototype, "menuDashboardId", void 0), Rh = Y([R("ods-gallery")], Rh); +//#endregion +//#region src/ods-layout-panel.ts +var zh = (e) => e === "columns" || e === "rows" || e === "gap", Bh = "padding", Vh = 1024, Hh = class extends L { + constructor(...e) { + super(...e), this.padding = 0; + } + static { + this.styles = [ + G, + K, + P` + :host { + display: contents; + } + .toolbox { + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + overflow: auto; + border-inline-end: 1px solid var(--studio-border); + background: var(--studio-surface); + } + .panel-title { + min-height: 58px; + display: flex; + align-items: center; + padding: 11px 12px; + } + .panel-title h2 { + margin: 1px 0 0; + font-size: 15px; + } + .section { + display: grid; + gap: var(--ha-space-2, 8px); + padding: var(--ha-space-1, 4px) var(--ha-space-3, 12px) + var(--ha-space-4, 16px); + } + .section h3 { + margin: 0; + color: var(--studio-muted); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + } + .fields { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--ha-space-2, 8px); + } + p { + margin: 0; + color: var(--studio-muted); + font-size: 12px; + line-height: 1.45; + } + .count { + color: var(--studio-text); + font-weight: 600; + } + ` + ]; + } + onFieldChange(e) { + e.stopPropagation(); + let { key: t, value: n } = e.detail, r = Number(n); + Number.isFinite(r) && (zh(t) && W(this, "grid-change", { [t]: r }), t === Bh && W(this, "display-number-change", { + key: Bh, + value: r + })); + } + renderField(e, t, n, r, i) { + return F` + + `; + } + renderGrid() { + let { minLines: e, maxLines: n, maxGap: r } = this.limits; + return F` +
+

${t.basic.gridHeading}

+
+ ${this.renderField("columns", this.layout.columns, t.basic.columns, e, n)} + ${this.renderField("rows", this.layout.rows, t.basic.rows, e, n)} + ${this.renderField("gap", this.layout.gap, t.basic.gap, 0, r)} + ${this.renderField(Bh, this.padding, t.fields.padding, 0, Vh)} +
+
+ `; + } + renderRegions() { + return F` +
+

${t.basic.regionsHeading}

+ + ${t.basic.regionCount(this.layout.regions.length)} + +

${t.basic.regionsHint}

+
+ `; + } + render() { + return !this.layout || !this.limits ? I : F` + + `; + } +}; +Y([z({ attribute: !1 })], Hh.prototype, "layout", void 0), Y([z({ attribute: !1 })], Hh.prototype, "limits", void 0), Y([z({ type: Number })], Hh.prototype, "padding", void 0), Hh = Y([R("ods-layout-panel")], Hh); //#endregion //#region src/font-file.ts -var wm = [".ttf", ".otf"], Tm = wm.join(","), Em = 2097152, Dm = (e) => wm.some((t) => e.toLowerCase().endsWith(t)), Om = async (e) => { +var Uh = [".ttf", ".otf"], Wh = Uh.join(","), Gh = 2097152, Kh = (e) => Uh.some((t) => e.toLowerCase().endsWith(t)), qh = async (e) => { let t = new Uint8Array(await e.arrayBuffer()), n = 32768, r = ""; for (let e = 0; e < t.length; e += n) r += String.fromCharCode(...t.subarray(e, e + n)); return btoa(r); -}, km = (e) => e < 1048576 ? `${Math.max(1, Math.round(e / 1024))} KB` : `${(e / 1048576).toFixed(1)} MB`, Am = 6, jm = (e, n) => { - let r = Sd(e); +}, Jh = (e) => e < 1048576 ? `${Math.max(1, Math.round(e / 1024))} KB` : `${(e / 1048576).toFixed(1)} MB`, Yh = 6, Xh = (e, n) => { + let r = jd(e); return (r ? t.settings.fonts.errors[r] : void 0) ?? U(e, n); -}, Mm = class extends L { +}, Zh = class extends L { constructor(...e) { super(...e), this.fonts = [], this.error = "", this.busy = !1, this.dragging = !1; } @@ -14122,7 +15116,7 @@ var wm = [".ttf", ".otf"], Tm = wm.join(","), Em = 2097152, Dm = (e) => wm.some( } async refresh() { if (this.hass) try { - this.show(await Fd(this.hass)); + this.show(await Gd(this.hass)); } catch (e) { this.error = U(e, t.settings.fonts.loadFailed); } @@ -14131,11 +15125,11 @@ var wm = [".ttf", ".otf"], Tm = wm.join(","), Em = 2097152, Dm = (e) => wm.some( e.has("hass") && this.fonts.length === 0 && this.refresh(); } show(e) { - this.fonts = e, Bd(e); + this.fonts = e, Xd(e); } async installFile(e) { if (this.hass) { - if (!Dm(e.name)) { + if (!Kh(e.name)) { this.error = t.settings.fonts.notFontFile(e.name); return; } @@ -14144,10 +15138,10 @@ var wm = [".ttf", ".otf"], Tm = wm.join(","), Em = 2097152, Dm = (e) => wm.some( return; } try { - let t = await Om(e); - this.show(await Id(this.hass, e.name, t)); + let t = await qh(e); + this.show(await Kd(this.hass, e.name, t)); } catch (e) { - this.error = jm(e, t.settings.fonts.installFailed); + this.error = Xh(e, t.settings.fonts.installFailed); } } } @@ -14187,15 +15181,15 @@ var wm = [".ttf", ".otf"], Tm = wm.join(","), Em = 2097152, Dm = (e) => wm.some( if (this.removing = void 0, !(!e || !this.hass)) { this.error = ""; try { - this.show(await Ld(this.hass, e.name, !0)); + this.show(await qd(this.hass, e.name, !0)); } catch (e) { - this.error = jm(e, t.settings.fonts.removeFailed); + this.error = Xh(e, t.settings.fonts.removeFailed); } } } removalBody(e) { if (e.usedBy.length === 0) return t.settings.fonts.removeUnusedBody; - let n = e.usedBy.slice(0, Am).map((e) => e.name).join(", "), r = e.usedBy.length - Am; + let n = e.usedBy.slice(0, Yh).map((e) => e.name).join(", "), r = e.usedBy.length - Yh; return t.settings.fonts.removeUsedBody(e.usedBy.length, r > 0 ? `${n}, +${r}` : n); } renderDrop() { @@ -14209,7 +15203,7 @@ var wm = [".ttf", ".otf"], Tm = wm.join(","), Em = 2097152, Dm = (e) => wm.some( ${t.settings.fonts.dropHint} - ${t.settings.fonts.limit(km(Em))} + ${t.settings.fonts.limit(Jh(Gh))} wm.some( wm.some(
${e.family} ${e.style} - ${e.name} · ${km(e.size)} + ${e.name} · ${Jh(e.size)}
${this.renderUsage(e)}
${this.renderAction(e)} @@ -14296,10 +15290,10 @@ var wm = [".ttf", ".otf"], Tm = wm.join(","), Em = 2097152, Dm = (e) => wm.some( `; } }; -Y([z({ attribute: !1 })], Mm.prototype, "hass", void 0), Y([B()], Mm.prototype, "fonts", void 0), Y([B()], Mm.prototype, "error", void 0), Y([B()], Mm.prototype, "busy", void 0), Y([B()], Mm.prototype, "dragging", void 0), Y([B()], Mm.prototype, "removing", void 0), Mm = Y([R("ods-settings-fonts")], Mm); +Y([z({ attribute: !1 })], Zh.prototype, "hass", void 0), Y([B()], Zh.prototype, "fonts", void 0), Y([B()], Zh.prototype, "error", void 0), Y([B()], Zh.prototype, "busy", void 0), Y([B()], Zh.prototype, "dragging", void 0), Y([B()], Zh.prototype, "removing", void 0), Zh = Y([R("ods-settings-fonts")], Zh); //#endregion //#region src/settings-stats.ts -var Nm = (e) => { +var Qh = (e) => { let t = /* @__PURE__ */ new Map(); for (let n of e) for (let e of E(n.items)) { if (e.kind !== "widget") continue; @@ -14307,8 +15301,8 @@ var Nm = (e) => { t.set(n, (t.get(n) ?? 0) + 1); } return t; -}, Pm = (e, t) => { - let n = new Map(t.map((e) => [e.id, e.name])), r = [...Nm(e)].map(([e, t]) => ({ +}, $h = (e, t) => { + let n = new Map(t.map((e) => [e.id, e.name])), r = [...Qh(e)].map(([e, t]) => ({ id: e, name: n.get(e) ?? e, count: t @@ -14318,7 +15312,7 @@ var Nm = (e) => { elements: e.reduce((e, t) => e + E(t.items).length, 0), widgetUse: r }; -}, Fm = class extends L { +}, eg = class extends L { constructor(...e) { super(...e), this.dashboards = [], this.widgets = [], this.fontCount = 0; } @@ -14416,7 +15410,7 @@ var Nm = (e) => { `; } render() { - let e = Pm(this.dashboards, this.widgets), n = t.settings.overview; + let e = $h(this.dashboards, this.widgets), n = t.settings.overview; return F`
    ${this.renderTile(n.dashboards, e.dashboards)} @@ -14429,10 +15423,10 @@ var Nm = (e) => { `; } }; -Y([z({ attribute: !1 })], Fm.prototype, "dashboards", void 0), Y([z({ attribute: !1 })], Fm.prototype, "widgets", void 0), Y([z({ type: Number })], Fm.prototype, "fontCount", void 0), Fm = Y([R("ods-settings-overview")], Fm); +Y([z({ attribute: !1 })], eg.prototype, "dashboards", void 0), Y([z({ attribute: !1 })], eg.prototype, "widgets", void 0), Y([z({ type: Number })], eg.prototype, "fontCount", void 0), eg = Y([R("ods-settings-overview")], eg); //#endregion //#region src/ods-settings-widgets.ts -var Im = class extends L { +var tg = class extends L { constructor(...e) { super(...e), this.widgets = [], this.errors = []; } @@ -14617,10 +15611,10 @@ var Im = class extends L { `; } }; -Y([z({ attribute: !1 })], Im.prototype, "widgets", void 0), Y([z({ attribute: !1 })], Im.prototype, "errors", void 0), Im = Y([R("ods-settings-widgets")], Im); +Y([z({ attribute: !1 })], tg.prototype, "widgets", void 0), Y([z({ attribute: !1 })], tg.prototype, "errors", void 0), tg = Y([R("ods-settings-widgets")], tg); //#endregion //#region src/ods-settings.ts -var Lm = () => ({ +var ng = () => ({ overview: { text: t.settings.about.overview, abilities: [] @@ -14651,7 +15645,7 @@ var Lm = () => ({ } ] } -}), Rm = () => [ +}), rg = () => [ { id: "overview", label: t.settings.tabs.overview, @@ -14667,7 +15661,7 @@ var Lm = () => ({ label: t.settings.tabs.widgets, icon: "mdi:widgets-outline" } -], zm = class extends L { +], ig = class extends L { constructor(...e) { super(...e), this.dashboards = [], this.widgets = [], this.widgetErrors = [], this.tab = "overview", this.fontCount = 0; } @@ -14786,7 +15780,7 @@ var Lm = () => ({ } async countFonts() { if (this.hass) try { - this.fontCount = (await zd(this.hass)).length; + this.fontCount = (await Yd(this.hass)).length; } catch { this.fontCount = 0; } @@ -14825,7 +15819,7 @@ var Lm = () => ({ `; } renderAbout() { - let e = Lm()[this.tab]; + let e = ng()[this.tab]; return F`

    ${e.text}

    @@ -14870,7 +15864,7 @@ var Lm = () => ({
${this.renderAbout()} ${this.renderBody()} @@ -14879,10 +15873,10 @@ var Lm = () => ({ `; } }; -Y([z({ attribute: !1 })], zm.prototype, "hass", void 0), Y([z({ attribute: !1 })], zm.prototype, "dashboards", void 0), Y([z({ attribute: !1 })], zm.prototype, "widgets", void 0), Y([z({ attribute: !1 })], zm.prototype, "widgetErrors", void 0), Y([B()], zm.prototype, "tab", void 0), Y([B()], zm.prototype, "fontCount", void 0), zm = Y([R("ods-settings")], zm); +Y([z({ attribute: !1 })], ig.prototype, "hass", void 0), Y([z({ attribute: !1 })], ig.prototype, "dashboards", void 0), Y([z({ attribute: !1 })], ig.prototype, "widgets", void 0), Y([z({ attribute: !1 })], ig.prototype, "widgetErrors", void 0), Y([B()], ig.prototype, "tab", void 0), Y([B()], ig.prototype, "fontCount", void 0), ig = Y([R("ods-settings")], ig); //#endregion //#region src/ods-header.ts -var Bm = class extends L { +var ag = class extends L { constructor(...e) { super(...e), this.view = "design", this.dirty = !1, this.saving = !1, this.sending = !1; } @@ -15036,7 +16030,7 @@ var Bm = class extends L { ]; } onNameInput(e) { - W(this, "dashboard-name-change", { name: dd(e) }); + W(this, "dashboard-name-change", { name: bd(e) }); } get statusToggleLabel() { return this.dashboard.status === "ready" ? t.header.setDraft : t.header.setReady; @@ -15146,13 +16140,13 @@ var Bm = class extends L { `; } }; -Y([z({ attribute: !1 })], Bm.prototype, "dashboard", void 0), Y([z()], Bm.prototype, "view", void 0), Y([z({ type: Boolean })], Bm.prototype, "dirty", void 0), Y([z({ type: Boolean })], Bm.prototype, "saving", void 0), Y([z({ type: Boolean })], Bm.prototype, "sending", void 0), Bm = Y([R("ods-header")], Bm); +Y([z({ attribute: !1 })], ag.prototype, "dashboard", void 0), Y([z()], ag.prototype, "view", void 0), Y([z({ type: Boolean })], ag.prototype, "dirty", void 0), Y([z({ type: Boolean })], ag.prototype, "saving", void 0), Y([z({ type: Boolean })], ag.prototype, "sending", void 0), ag = Y([R("ods-header")], ag); //#endregion //#region src/item-labels.ts -var Vm = (e, t, n) => { +var og = (e, t, n) => { if (e.kind === "widget") return t.find((t) => t.id === e.widget.type); if (e.kind !== "container") return n.find((t) => t.type === e.primitive.type); -}, Hm = (e, t, n) => e.kind === "container" ? e.grouped ? "mdi:group" : "mdi:select-all" : Vm(e, t, n)?.icon ?? "mdi:puzzle", Um = (e, t) => JSON.stringify(e ?? null) === JSON.stringify(t ?? null), Wm = (e, t, n = {}) => e.filter((e) => e.key in n || !Um(t[e.key], e.default)), Gm = (e) => Object.fromEntries(e.map((e) => [e.key, e.default ?? null])), Km = "{{ }}", qm = class extends L { +}, sg = (e, t, n) => e.kind === "container" ? e.grouped ? "mdi:group" : "mdi:select-all" : og(e, t, n)?.icon ?? "mdi:puzzle", cg = (e, t) => JSON.stringify(e ?? null) === JSON.stringify(t ?? null), lg = (e, t, n = {}) => e.filter((e) => e.key in n || !cg(t[e.key], e.default)), ug = (e) => Object.fromEntries(e.map((e) => [e.key, e.default ?? null])), dg = "{{ }}", fg = class extends L { constructor(...e) { super(...e), this.label = "", this.fieldKey = "", this.disabled = !1, this.focusEditor = !1; } @@ -15237,7 +16231,7 @@ var Vm = (e, t, n) => { } onLiteralInput(e) { let t = e.composedPath()[0]; - t instanceof HTMLInputElement && t.type === "text" && t.value.trimStart().startsWith("{") && (this.focusEditor = !0, this.change(Km)); + t instanceof HTMLInputElement && t.type === "text" && t.value.trimStart().startsWith("{") && (this.focusEditor = !0, this.change(dg)); } onEditorChange(e) { if (!(e.target instanceof HTMLTextAreaElement)) return; @@ -15285,30 +16279,30 @@ var Vm = (e, t, n) => { `; } }; -Y([z()], qm.prototype, "label", void 0), Y([z()], qm.prototype, "fieldKey", void 0), Y([z()], qm.prototype, "expression", void 0), Y([z({ type: Boolean })], qm.prototype, "disabled", void 0), Y([bl("textarea")], qm.prototype, "editor", void 0), qm = Y([R("ods-expression-field")], qm); +Y([z()], fg.prototype, "label", void 0), Y([z()], fg.prototype, "fieldKey", void 0), Y([z()], fg.prototype, "expression", void 0), Y([z({ type: Boolean })], fg.prototype, "disabled", void 0), Y([Sl("textarea")], fg.prototype, "editor", void 0), fg = Y([R("ods-expression-field")], fg); //#endregion //#region src/structure-model.ts -var Jm = (e, t) => `${e.name} ${p(e)}`.toLocaleLowerCase().includes(t), Ym = (e, t) => { +var pg = (e, t) => `${e.name} ${p(e)}`.toLocaleLowerCase().includes(t), mg = (e, t) => { let n = /* @__PURE__ */ new Set(); - for (let r of E(e)) if (Jm(r, t)) { + for (let r of E(e)) if (pg(r, t)) { n.add(r.id); for (let t of Ot(e, r.id)) n.add(t.id); } return n; -}, Xm = (e, t, n, r) => [...e].reverse().flatMap((e) => { +}, hg = (e, t, n, r) => [...e].reverse().flatMap((e) => { if (r && !r.has(e.id)) return []; let i = { item: e, depth: t }; - return C(e) && (r || !n.has(e.id)) ? [i, ...Xm(e.children, t + 1, n, r)] : [i]; -}), Zm = (e, t, n = "") => { + return C(e) && (r || !n.has(e.id)) ? [i, ...hg(e.children, t + 1, n, r)] : [i]; +}), gg = (e, t, n = "") => { let r = n.trim().toLocaleLowerCase(); - return Xm(e, 0, t, r ? Ym(e, r) : void 0); -}, Qm = (e, t) => t && e >= .25 && e <= .75 ? "inside" : e < .5 ? "before" : "after", $m = (e, t, n) => { + return hg(e, 0, t, r ? mg(e, r) : void 0); +}, _g = (e, t) => t && e >= .25 && e <= .75 ? "inside" : e < .5 ? "before" : "after", vg = (e, t, n) => { let r = e.findIndex((e) => e.item.id === t); return r < 0 ? e[0] : e[Math.min(e.length - 1, Math.max(0, r + n))]; -}, eh = 4, th = 16, nh = (e) => { +}, yg = 4, bg = 16, xg = (e) => { let t = [ "toggle-hidden", "toggle-locked", @@ -15434,14 +16428,14 @@ var Jm = (e, t) => `${e.name} ${p(e)}`.toLocaleLowerCase().includes(t), Ym = (e, align-items: center; min-height: 34px; gap: 4px; - padding: 2px 3px 2px calc(var(--depth, 0) * ${th}px + 3px); + padding: 2px 3px 2px calc(var(--depth, 0) * ${bg}px + 3px); border: 1px solid transparent; border-radius: 5px; } .layer-row[data-depth]:not([data-depth="0"])::after { content: ""; position: absolute; - left: calc(var(--depth) * ${th}px - 5px); + left: calc(var(--depth) * ${bg}px - 5px); top: 0; bottom: 0; width: 1px; @@ -15621,7 +16615,7 @@ var Jm = (e, t) => `${e.name} ${p(e)}`.toLocaleLowerCase().includes(t), Ym = (e, n.has(t.id) ? n.delete(t.id) : n.add(t.id), this.collapsed = n; } onSearchInput(e) { - this.searchText = dd(e); + this.searchText = bd(e); } startRename(e) { this.renamingId = e.id, this.updateComplete.then(() => this.shadowRoot?.querySelector(".rename")?.select()); @@ -15629,7 +16623,7 @@ var Jm = (e, t) => `${e.name} ${p(e)}`.toLocaleLowerCase().includes(t), Ym = (e, commitRename(e, t) { this.renamingId === t.id && (this.renamingId = "", W(this, "item-rename", { itemId: t.id, - name: dd(e) + name: bd(e) })); } onRenameKeyDown(e, t) { @@ -15640,9 +16634,9 @@ var Jm = (e, t) => `${e.name} ${p(e)}`.toLocaleLowerCase().includes(t), Ym = (e, e.button !== 0 || !(n instanceof HTMLElement) || n.closest("button, input") || this.startDrag(e, t.id); } startDrag(e, t) { - this.stopGesture = Of({ + this.stopGesture = gp({ origin: e, - threshold: eh, + threshold: yg, onActivate: () => { this.draggingId = t, this.suppressClick = !0; }, @@ -15674,7 +16668,7 @@ var Jm = (e, t) => `${e.name} ${p(e)}`.toLocaleLowerCase().includes(t), Ym = (e, let a = n.getBoundingClientRect(); return { itemId: i.id, - zone: Qm((e.clientY - a.top) / a.height, C(i)) + zone: _g((e.clientY - a.top) / a.height, C(i)) }; } clearDrag() { @@ -15692,7 +16686,7 @@ var Jm = (e, t) => `${e.name} ${p(e)}`.toLocaleLowerCase().includes(t), Ym = (e, selectRow(e, t) { this.suppressClick || W(this, "item-select", { itemId: t.id, - additive: md(e) + additive: Cd(e) }); } selectRoot() { @@ -15717,10 +16711,10 @@ var Jm = (e, t) => `${e.name} ${p(e)}`.toLocaleLowerCase().includes(t), Ym = (e, } onTreeKeyDown(e) { if (e.target instanceof HTMLInputElement) return; - let t = Zm(this.items, this.collapsed, this.searchText), n = T(this.items, this.selectedItemId); + let t = gg(this.items, this.collapsed, this.searchText), n = T(this.items, this.selectedItemId); if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault(); - let n = $m(t, this.selectedItemId, e.key === "ArrowDown" ? 1 : -1); + let n = vg(t, this.selectedItemId, e.key === "ArrowDown" ? 1 : -1); n && this.focusAndSelect(n); } e.key === "ArrowLeft" && n && (e.preventDefault(), this.foldOrGoUp(n, t)), e.key === "ArrowRight" && n && (e.preventDefault(), this.unfoldOrGoDown(n, t)); @@ -15747,9 +16741,9 @@ var Jm = (e, t) => `${e.name} ${p(e)}`.toLocaleLowerCase().includes(t), Ym = (e, r && this.focusAndSelect(r); } renderCommandButton(e, t) { - let n = uu(t), r = wu(e); + let n = fu(t), r = Ou(e); if (!r.isEnabled(n)) return I; - let i = Au(r, n, pd()); + let i = Pu(r, n, Sd()); return F`
`; @@ -15855,7 +16849,7 @@ var Jm = (e, t) => `${e.name} ${p(e)}`.toLocaleLowerCase().includes(t), Ym = (e, let e = this.dropTarget?.itemId === "" && this.dropTarget.zone === "inside"; return F`
`${e.name} ${p(e)}`.toLocaleLowerCase().includes(t), Ym = (e, ` : I; } renderRows() { - let e = Zm(this.items, this.collapsed, this.searchText); + let e = gg(this.items, this.collapsed, this.searchText); return this.items.length === 0 ? F`

${t.structure.empty}

` : F` @@ -15929,15 +16923,15 @@ var Jm = (e, t) => `${e.name} ${p(e)}`.toLocaleLowerCase().includes(t), Ym = (e, `; } }; -Y([z({ attribute: !1 })], Z.prototype, "items", void 0), Y([z({ attribute: !1 })], Z.prototype, "widgets", void 0), Y([z({ attribute: !1 })], Z.prototype, "primitives", void 0), Y([z()], Z.prototype, "selectedItemId", void 0), Y([z({ attribute: !1 })], Z.prototype, "selectedItemIds", void 0), Y([z()], Z.prototype, "enteredGroupId", void 0), Y([z()], Z.prototype, "renameRequestId", void 0), Y([B()], Z.prototype, "collapsed", void 0), Y([B()], Z.prototype, "searchText", void 0), Y([B()], Z.prototype, "renamingId", void 0), Y([B()], Z.prototype, "draggingId", void 0), Y([B()], Z.prototype, "dropTarget", void 0), Y([bl(".layer-list")], Z.prototype, "layerList", void 0), Z = Y([R("ods-structure")], Z); +Y([z({ attribute: !1 })], Z.prototype, "items", void 0), Y([z({ attribute: !1 })], Z.prototype, "widgets", void 0), Y([z({ attribute: !1 })], Z.prototype, "primitives", void 0), Y([z()], Z.prototype, "selectedItemId", void 0), Y([z({ attribute: !1 })], Z.prototype, "selectedItemIds", void 0), Y([z()], Z.prototype, "enteredGroupId", void 0), Y([z()], Z.prototype, "renameRequestId", void 0), Y([B()], Z.prototype, "collapsed", void 0), Y([B()], Z.prototype, "searchText", void 0), Y([B()], Z.prototype, "renamingId", void 0), Y([B()], Z.prototype, "draggingId", void 0), Y([B()], Z.prototype, "dropTarget", void 0), Y([Sl(".layer-list")], Z.prototype, "layerList", void 0), Z = Y([R("ods-structure")], Z); //#endregion //#region src/ods-inspector.ts -var rh = 340, ih = 560, ah = () => ({ +var Sg = 340, Cg = 560, wg = () => ({ key: "background", label: t.fields.background, shape: "color", section: "appearance" -}), oh = ["padding", "snapSize"], sh = (e) => oh.some((t) => t === e), ch = (e) => e.label, Q = class extends L { +}), Tg = ["padding", "snapSize"], Eg = (e) => Tg.some((t) => t === e), Dg = (e) => e.label, Q = class extends L { constructor(...e) { super(...e), this.widgets = [], this.primitives = [], this.selectedItemId = "", this.selectedItemIds = [], this.enteredGroupId = "", this.renameRequestId = "", this.collapsed = !1, this.width = 350, this.showCorners = !1; } @@ -16239,11 +17233,11 @@ var rh = 340, ih = 560, ah = () => ({ startResize(e) { e.preventDefault(); let t = e.clientX, n = this.width; - this.stopGesture = Of({ + this.stopGesture = gp({ origin: e, onMove: (e) => { let r = n + t - e.clientX; - W(this, "inspector-resize", { width: _(r, rh, ih) }); + W(this, "inspector-resize", { width: _(r, Sg, Cg) }); } }); } @@ -16265,7 +17259,7 @@ var rh = 340, ih = 560, ah = () => ({ onDisplayFieldChange(e) { e.stopPropagation(); let { key: t } = e.detail, n = this.numberFrom(e); - n !== void 0 && sh(t) && W(this, "display-number-change", { + n !== void 0 && Eg(t) && W(this, "display-number-change", { key: t, value: n }); @@ -16343,12 +17337,21 @@ var rh = 340, ih = 560, ah = () => ({ renderDashboardBackground() { return F` + `; + } + renderWorkingArea() { + return F` +
+ ${this.renderDisplayField(t.fields.padding, "padding", 0, 1024)} + ${this.renderDisplayField(t.fields.snapSize, "snapSize", 1, 256)} +
+

${t.inspector.workingAreaHelp}

`; } renderDashboardInspector() { @@ -16358,11 +17361,7 @@ var rh = 340, ih = 560, ah = () => ({ ${t.inspector.appearance}
${this.renderDashboardBackground()} -
- ${this.renderDisplayField(t.fields.padding, "padding", 0, 1024)} - ${this.renderDisplayField(t.fields.snapSize, "snapSize", 1, 256)} -
-

${t.inspector.workingAreaHelp}

+ ${this.isBasic ? I : this.renderWorkingArea()}
${this.renderMetrics()} @@ -16493,7 +17492,7 @@ var rh = 340, ih = 560, ah = () => ({ .hass=${this.hass} .data=${{ [e.key]: t }} .schema=${[this.formSchema(e)]} - .computeLabel=${ch} + .computeLabel=${Dg} @value-changed=${(t) => n(e.key, t.detail.value[e.key])} >
@@ -16528,7 +17527,7 @@ var rh = 340, ih = 560, ah = () => ({ .hass=${this.hass} .data=${{ [t.key]: nc(t, n) }} .schema=${[this.formSchema(t, t.required)]} - .computeLabel=${ch} + .computeLabel=${Dg} @value-changed=${(n) => this.onPicksChange(n, t, e)} > ${t.perSource.length > 0 ? n.map((n) => this.renderPickFields(e, t, n)) : I} @@ -16569,10 +17568,10 @@ var rh = 340, ih = 560, ah = () => ({ `; } renderOptionSection(e, t, n) { - let r = Wm(t.fields.filter((e) => e.default !== void 0), e.widget.options); + let r = lg(t.fields.filter((e) => e.default !== void 0), e.widget.options); return F`
- ${this.renderResettableSummary(t.section, r.length > 0, () => W(this, "widget-options-change", { value: Gm(r) }))} + ${this.renderResettableSummary(t.section, r.length > 0, () => W(this, "widget-options-change", { value: ug(r) }))}
${t.fields.map((t) => this.renderWidgetField(t, e.widget.options[t.key], (e, t) => this.setWidgetOption(e, t)))} @@ -16637,17 +17636,17 @@ var rh = 340, ih = 560, ah = () => ({ renderAdvancedFields(e) { let n = es(e, this.primitives, "appearance").filter((e) => e.advanced); return n.length === 0 ? I : F` -
0}> +
0}> ${t.inspector.advanced} ${this.renderValueFields(e, "appearance", !0)}
`; } renderAppearance(e) { - let n = Wm(es(e, this.primitives, "appearance").filter((e) => !e.required || e.default !== void 0), { ...e.primitive }, e.expressions); + let n = lg(es(e, this.primitives, "appearance").filter((e) => !e.required || e.default !== void 0), { ...e.primitive }, e.expressions); return F`
- ${this.renderResettableSummary(t.inspector.appearance, n.length > 0, () => W(this, "primitive-fields-reset", { values: Gm(n) }))} + ${this.renderResettableSummary(t.inspector.appearance, n.length > 0, () => W(this, "primitive-fields-reset", { values: ug(n) }))}
${this.renderValueFields(e, "appearance")} ${this.renderAdvancedFields(e)} @@ -16695,7 +17694,8 @@ var rh = 340, ih = 560, ah = () => ({ } renderItemSections(e) { return e.kind === "widget" ? F` - ${this.renderLayoutSection(e)} ${this.renderWidgetSettings(e)} + ${this.isBasic ? I : this.renderLayoutSection(e)} + ${this.renderWidgetSettings(e)} ` : e.kind === "container" ? F` ${this.renderLayoutSection(e)} ${this.renderContainerBackground(e)} @@ -16718,13 +17718,13 @@ var rh = 340, ih = 560, ah = () => ({ } renderItemInspector(e) { return F` - ${this.renderHeader(e.name, t.inspector.subtitle(this.kindOf(e), e.locked), Hm(e, this.widgets, this.primitives))} + ${this.renderHeader(e.name, t.inspector.subtitle(this.kindOf(e), e.locked), sg(e, this.widgets, this.primitives))} ${this.renderGroupedChip(e)} ${this.renderLockedNotice(e)} ${this.renderItemSections(e)} ${this.renderMetrics()} `; } renderMultiInspector() { - let e = this.selectedItemIds.length, n = Tf(this.dashboard, this.selectedItemIds, (e) => this.preview?.itemBounds[e.id]); + let e = this.selectedItemIds.length, n = pp(this.dashboard, this.selectedItemIds, (e) => this.preview?.itemBounds[e.id]); return F` ${this.renderHeader(t.inspector.selectedElements(e), t.inspector.selectionHint, "mdi:select-multiple")} ${n ? F` @@ -16770,6 +17770,22 @@ var rh = 340, ih = 560, ah = () => ({ renderProperties(e) { return this.selectedItemIds.length > 1 ? this.renderMultiInspector() : e ? this.renderItemInspector(e) : this.renderDashboardInspector(); } + get isBasic() { + return this.dashboard.mode === "basic"; + } + renderStructure() { + return this.isBasic ? I : F` + + `; + } render() { if (this.collapsed) return this.renderRail(); let e = T(this.dashboard.items, this.selectedItemId); @@ -16782,50 +17798,42 @@ var rh = 340, ih = 560, ah = () => ({ aria-label=${t.inspector.resize} @pointerdown=${this.startResize} >
- + ${this.renderStructure()}
${this.renderProperties(e)}
`; } }; -Y([z({ attribute: !1 })], Q.prototype, "hass", void 0), Y([z({ attribute: !1 })], Q.prototype, "dashboard", void 0), Y([z({ attribute: !1 })], Q.prototype, "widgets", void 0), Y([z({ attribute: !1 })], Q.prototype, "primitives", void 0), Y([z({ attribute: !1 })], Q.prototype, "preview", void 0), Y([z()], Q.prototype, "selectedItemId", void 0), Y([z({ attribute: !1 })], Q.prototype, "selectedItemIds", void 0), Y([z()], Q.prototype, "enteredGroupId", void 0), Y([z()], Q.prototype, "renameRequestId", void 0), Y([z({ type: Boolean })], Q.prototype, "collapsed", void 0), Y([z({ type: Number })], Q.prototype, "width", void 0), Y([B()], Q.prototype, "showCorners", void 0), Y([bl(".properties")], Q.prototype, "propertiesPanel", void 0), Q = Y([R("ods-inspector")], Q); +Y([z({ attribute: !1 })], Q.prototype, "hass", void 0), Y([z({ attribute: !1 })], Q.prototype, "dashboard", void 0), Y([z({ attribute: !1 })], Q.prototype, "widgets", void 0), Y([z({ attribute: !1 })], Q.prototype, "primitives", void 0), Y([z({ attribute: !1 })], Q.prototype, "preview", void 0), Y([z()], Q.prototype, "selectedItemId", void 0), Y([z({ attribute: !1 })], Q.prototype, "selectedItemIds", void 0), Y([z()], Q.prototype, "enteredGroupId", void 0), Y([z()], Q.prototype, "renameRequestId", void 0), Y([z({ type: Boolean })], Q.prototype, "collapsed", void 0), Y([z({ type: Number })], Q.prototype, "width", void 0), Y([B()], Q.prototype, "showCorners", void 0), Y([Sl(".properties")], Q.prototype, "propertiesPanel", void 0), Q = Y([R("ods-inspector")], Q); //#endregion //#region src/catalog.ts -var lh = (e, t) => { +var Og = (e, t) => { let n = t.trim().toLocaleLowerCase(); return n ? e.filter((e) => `${e.name} ${e.description}`.toLocaleLowerCase().includes(n)) : e; -}, uh = [ +}, kg = [ "text", "shapes", "media", "data", "debug" -], dh = (e) => { - let t = [...uh], n = [...new Set(e.map((e) => e.category).filter((e) => !t.includes(e)))]; +], Ag = (e) => { + let t = [...kg], n = [...new Set(e.map((e) => e.category).filter((e) => !t.includes(e)))]; return [...t, ...n].map((t) => ({ id: t, entries: e.filter((e) => e.category === t) })).filter((e) => e.entries.length > 0); -}, fh = (e) => { +}, jg = (e) => { let t = /* @__PURE__ */ new Map(); for (let n of e) t.set(n.category, [...t.get(n.category) ?? [], n]); return [...t.entries()]; -}, ph = 4, mh = () => ({ +}, Mg = 4, Ng = () => ({ id: "container", name: t.library.container, description: t.library.containerHint, icon: "mdi:select-all" -}), hh = class extends L { +}), Pg = class extends L { constructor(...e) { - super(...e), this.widgets = [], this.widgetErrors = [], this.primitives = [], this.collapsed = !1, this.searchText = "", this.foldedSections = /* @__PURE__ */ new Set(), this.suppressClick = !1; + super(...e), this.widgets = [], this.widgetErrors = [], this.primitives = [], this.collapsed = !1, this.widgetsOnly = !1, this.searchText = "", this.foldedSections = /* @__PURE__ */ new Set(), this.suppressClick = !1; } static { this.styles = [ @@ -17072,9 +18080,9 @@ var lh = (e, t) => { if (e.button !== 0) return; e.preventDefault(); let r = e.currentTarget.getBoundingClientRect(), i = e.clientX - r.left, a = e.clientY - r.top; - this.stopGesture = Of({ + this.stopGesture = gp({ origin: e, - threshold: ph, + threshold: Mg, onActivate: () => W(this, "catalog-drag", { active: !0 }), onMove: (e) => { e.preventDefault(); @@ -17135,7 +18143,7 @@ var lh = (e, t) => { `; } onSearchInput(e) { - this.searchText = dd(e); + this.searchText = bd(e); } addFromClick(e) { this.suppressClick || W(this, "catalog-add", { value: e }); @@ -17188,7 +18196,7 @@ var lh = (e, t) => { return e.length === 0 ? F`

${t.library.noWidgets}

` : F` - ${fh(e).map(([e, t]) => F` + ${jg(e).map(([e, t]) => F`

${e}

${t.map((e) => this.renderEntry({ @@ -17250,15 +18258,19 @@ var lh = (e, t) => { `, n); } renderSections(e, n, r) { + if (this.widgetsOnly) return this.renderWidgetSection(r); let i = this.searchText !== ""; return i && !e.length && !n.length && !r.length ? F`

${t.library.noMatches}

` : F` ${i && e.length === 0 ? I : this.renderContainerSection(e)} - ${dh(n).map((e) => this.renderPrimitiveSection(e.id, e.entries))} + ${Ag(n).map((e) => this.renderPrimitiveSection(e.id, e.entries))} ${i && r.length === 0 ? I : this.renderWidgetSection(r)} `; } + get heading() { + return this.widgetsOnly ? t.basic.widgetsHeading : t.library.heading; + } render() { if (this.collapsed) return F` `; - let e = lh(this.widgets, this.searchText), n = lh([mh()], this.searchText), r = lh(this.primitives, this.searchText).map((e) => ({ + let e = Og(this.widgets, this.searchText), n = Og([Ng()], this.searchText), r = Og(this.primitives, this.searchText).map((e) => ({ ...e, id: e.type })); @@ -17283,7 +18295,7 @@ var lh = (e, t) => {
${t.library.title} -

${t.library.heading}

+

${this.heading}

+ `; + } + renderSource(e, t, n, r) { + return this.renderChoice(this.source === e, t, n, r, () => W(this, "new-dashboard-source", { source: e })); + } + renderMode(e, t, n, r) { + return this.renderChoice(this.dashboard.mode === e, t, n, r, () => W(this, "new-dashboard-mode", { mode: e })); + } + renderModes() { + let e = t.basic; + return F` + ${e.modeTitle} +
+ ${this.renderMode("basic", "mdi:view-grid-plus", e.basic, e.basicHint)} + ${this.renderMode("advanced", "mdi:vector-arrange-below", e.advanced, e.advancedHint)} +
`; } renderSources() { @@ -17468,32 +18561,16 @@ var gh = class extends L {
`; } - renderDisplaySummary() { - let { width: e, height: n, palette: r } = this.dashboard.display; + renderForm(e, t = !1) { return F` -
-
-
${t.fields.resolution}
-
${t.common.sizeInPixels(e, n)}
-
-
-
${t.newDashboard.colors}
-
- ${xo[r].map((e) => F` - - `)} - ${bo[r]} -
-
-
+ `; } renderPicker(e, t, n, r, i) { @@ -17509,51 +18586,107 @@ var gh = class extends L { options: n } } }]} - .computeLabel=${sd} + .computeLabel=${gd} @value-changed=${i} > `; } renderDevicePicker() { - if (this.devices.length === 0) return F` - ${t.newDashboard.noDevices} - `; let e = this.devices.map((e) => ({ value: e.id, label: `${e.name} · ${t.common.size(e.width, e.height)}` })); - return F` - ${this.renderPicker("deviceId", t.newDashboard.device, e, this.deviceId, this.deviceChanged)} - ${this.renderDisplaySummary()} - `; + return this.renderPicker("deviceId", t.newDashboard.device, e, this.deviceId, this.deviceChanged); } renderProfilePicker() { - let e = Zu.map((e) => ({ + let e = nd.map((e) => ({ value: e.id, label: `${e.manufacturer} · ${e.name}` })); + return this.renderPicker("profileId", t.newDashboard.display, e, this.dashboard.display.profileId ?? "", this.profileChanged); + } + get palettes() { + return this.source === "custom" ? td : this.source === "preset" ? Oo(this.dashboard.display.profileId).palettes : []; + } + renderPickerAndName() { + if (this.source === "device" && this.devices.length === 0) return F` + ${t.newDashboard.noDevices} + ${this.renderForm(md(), !0)} + `; + if (this.source === "custom") { + let e = this.palettes; + return F` +
+ ${this.renderForm(md(), !0)} + ${this.renderForm(sd(e))} +
+ ${this.renderForm(od())} + `; + } return F` - ${this.renderPicker("profileId", t.newDashboard.display, e, this.dashboard.display.profileId ?? "", this.profileChanged)} - ${this.renderDisplaySummary()} +
${this.source === "device" ? this.renderDevicePicker() : this.renderProfilePicker()} ${this.renderForm(md(), !0)}
+ ${this.palettes.length > 1 ? this.renderForm(sd(this.palettes)) : I} `; } - renderSourceBody() { - return this.source === "device" ? this.renderDevicePicker() : this.source === "preset" ? this.renderProfilePicker() : I; - } - displayFields() { - return this.source === "custom" ? { - size: !0, - palettes: Xu, - backgrounds: [] - } : this.source === "preset" ? { - size: !1, - palettes: Oo(this.dashboard.display.profileId).palettes, - backgrounds: [] - } : { - size: !1, - palettes: [], - backgrounds: [] - }; + turn(e) { + let t = Bu(this.dashboard.display.rotation, e); + W(this, "new-dashboard-change", { value: { rotation: String(t) } }); + } + renderTurnButton(e, t, n) { + return F` + + `; + } + renderPaletteSummary() { + let { palette: e } = this.dashboard.display; + return F` + + + ${xo[e].map((e) => F` + + `)} + + ${bo[e]} + + `; + } + renderPreview() { + let { display: e } = this.dashboard, n = t.newDashboard; + return F` +
+
+ ${this.renderTurnButton(-1, n.turnLeft, "mdi:rotate-left")} + ${this.renderTurnButton(1, n.turnRight, "mdi:rotate-right")} + +
+
+ + ${t.common.sizeInPixels(e.width, e.height)} · + ${e.rotation}° + + ${this.renderPaletteSummary()} +
+
+ `; + } + renderAdvanced() { + return this.dashboard.mode === "basic" ? I : this.renderForm(pd()); } render() { return F` @@ -17566,16 +18699,10 @@ var gh = class extends L { @closed=${this.close} >
+ ${this.renderModes()} ${t.newDashboard.startFrom} - ${this.renderSources()} ${this.renderSourceBody()} - + ${this.renderSources()} ${this.renderPickerAndName()} + ${this.renderPreview()} ${this.renderAdvanced()}
${this.saving ? t.newDashboard.creating : t.newDashboard.create} @@ -17598,21 +18725,21 @@ var gh = class extends L { `; } }; -Y([z({ attribute: !1 })], gh.prototype, "hass", void 0), Y([z({ attribute: !1 })], gh.prototype, "dashboard", void 0), Y([z({ attribute: !1 })], gh.prototype, "devices", void 0), Y([z()], gh.prototype, "source", void 0), Y([z()], gh.prototype, "deviceId", void 0), Y([z({ type: Boolean })], gh.prototype, "saving", void 0), gh = Y([R("ods-new-dashboard-dialog")], gh); +Y([z({ attribute: !1 })], Ig.prototype, "hass", void 0), Y([z({ attribute: !1 })], Ig.prototype, "dashboard", void 0), Y([z({ attribute: !1 })], Ig.prototype, "devices", void 0), Y([z()], Ig.prototype, "source", void 0), Y([z()], Ig.prototype, "deviceId", void 0), Y([z({ type: Boolean })], Ig.prototype, "saving", void 0), Ig = Y([R("ods-new-dashboard-dialog")], Ig); //#endregion //#region src/ods-app.ts -var _h = (e, t) => Ot(e.items, t).map((e) => e.id), vh = "opendisplay_studio.clipboard", yh = 228, bh = 36, xh = 9, Sh = 8, Ch = () => { +var Lg = (e, t) => Ot(e.items, t).map((e) => e.id), Rg = "opendisplay_studio.clipboard", zg = 228, Bg = 36, Vg = 9, Hg = 8, Ug = () => { try { - let e = window.localStorage.getItem(vh), t = e ? JSON.parse(e) : void 0; - return wh(t) ? t : void 0; + let e = window.localStorage.getItem(Rg), t = e ? JSON.parse(e) : void 0; + return Wg(t) ? t : void 0; } catch { return; } -}, wh = (e) => typeof e == "object" && !!e && "items" in e && Array.isArray(e.items), Th = (e) => { +}, Wg = (e) => typeof e == "object" && !!e && "items" in e && Array.isArray(e.items), Gg = (e) => { try { - window.localStorage.setItem(vh, JSON.stringify(e)); + window.localStorage.setItem(Rg, JSON.stringify(e)); } catch {} -}, Eh = 300, Dh = "debug_grid", Oh = (e, t) => e === "left" ? { +}, Kg = 300, qg = "debug_grid", Jg = (e, t) => e === "left" ? { dx: -t, dy: 0 } : e === "right" ? { @@ -17624,9 +18751,9 @@ var _h = (e, t) => Ot(e.items, t).map((e) => e.id), vh = "opendisplay_studio.cli } : { dx: 0, dy: t -}, kh = 220, Ah = 5e3, $ = class extends L { +}, Yg = 220, Xg = 5e3, $ = class extends L { constructor(...e) { - super(...e), this.dashboards = [], this.view = "dashboards", this.widgets = [], this.widgetErrors = [], this.notice = "", this.sending = !1, this.primitives = [], this.selection = [], this.enteredGroupId = "", this.shortcutsOpen = !1, this.renameRequestId = "", this.clipboard = Ch(), this.loading = !0, this.saving = !1, this.dirty = !1, this.error = "", this.draggingCatalog = !1, this.undoCount = 0, this.redoCount = 0, this.pendingDeleteIds = [], this.leftCollapsed = !1, this.rightCollapsed = !1, this.inspectorWidth = 350, this.snapEnabled = !0, this.alignTo = "selection", this.viewport = Es, this.newDashboardOpen = !1, this.newDashboard = Vu("en"), this.newDashboardSource = "custom", this.newDashboardDeviceId = "", this.displayDevices = [], this.previewRequest = 0, this.bootstrapStarted = !1, this.history = new Wd(), this.undo = () => { + super(...e), this.dashboards = [], this.view = "dashboards", this.widgets = [], this.widgetErrors = [], this.notice = "", this.sending = !1, this.primitives = [], this.selection = [], this.enteredGroupId = "", this.shortcutsOpen = !1, this.renameRequestId = "", this.clipboard = Ug(), this.loading = !0, this.saving = !1, this.dirty = !1, this.error = "", this.draggingCatalog = !1, this.undoCount = 0, this.redoCount = 0, this.pendingDeleteIds = [], this.leftCollapsed = !1, this.rightCollapsed = !1, this.inspectorWidth = 350, this.snapEnabled = !0, this.alignTo = "selection", this.viewport = Es, this.newDashboardOpen = !1, this.newDashboard = Ku("en"), this.newDashboardSource = "custom", this.newDashboardDeviceId = "", this.displayDevices = [], this.basicStep = "layout", this.selectedRegionId = "", this.previewRequest = 0, this.layoutRequest = 0, this.layoutKey = "", this.bootstrapStarted = !1, this.history = new Nf(), this.undo = () => { this.current && this.restore(this.history.undo(this.current)); }, this.redo = () => { this.current && this.restore(this.history.redo(this.current)); @@ -17637,7 +18764,7 @@ var _h = (e, t) => Ot(e.items, t).map((e) => e.id), vh = "opendisplay_studio.cli this.pendingDeleteIds = e; }, toggleFlag: (e, t) => this.mutate((n) => { - for (let r of e) Fl(n, r, t); + for (let r of e) Ll(n, r, t); }), group: (e) => this.groupSelection(e), ungroup: (e) => this.ungroup(e), @@ -17672,8 +18799,8 @@ var _h = (e, t) => Ot(e.items, t).map((e) => e.id), vh = "opendisplay_studio.cli this.shortcutsOpen = !1; return; } - if (fd(e) || this.view === "dashboards" || this.view === "settings") return; - let t = Du(e, this.commandContext()); + if (xd(e) || this.view === "dashboards" || this.view === "settings") return; + let t = ju(e, this.commandContext()); !t || this.view === "code" && !t.anywhere || (e.preventDefault(), this.runCommand(t.id)); }, this.lastNudgeAt = 0; } @@ -17734,6 +18861,12 @@ var _h = (e, t) => Ot(e.items, t).map((e) => e.id), vh = "opendisplay_studio.cli ); overflow: hidden; } + .basic-left { + min-width: 0; + min-height: 0; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + } .dashboard-empty { position: relative; height: 100%; @@ -17772,7 +18905,7 @@ var _h = (e, t) => Ot(e.items, t).map((e) => e.id), vh = "opendisplay_studio.cli e.has("hass") && this.hass && f(this.hass.language); } updated(e) { - e.has("hass") && (this.ensureBootstrap(), this.refreshOnStateChange(e.get("hass"))); + e.has("hass") && (this.ensureBootstrap(), this.refreshOnStateChange(e.get("hass"))), this.refreshLayoutFrames(); } disconnectedCallback() { super.disconnectedCallback(), this.previewTimer && window.clearTimeout(this.previewTimer), this.stateTimer && window.clearTimeout(this.stateTimer), this.clockTimer && window.clearTimeout(this.clockTimer), this.noticeTimer && window.clearTimeout(this.noticeTimer), window.removeEventListener("keydown", this.onKeyDown); @@ -17785,8 +18918,8 @@ var _h = (e, t) => Ot(e.items, t).map((e) => e.id), vh = "opendisplay_studio.cli if (e) { this.loading = !0, this.error = ""; try { - let t = await Cd(e); - this.dashboards = t.dashboards, this.widgets = t.widgets, this.widgetErrors = t.widgetErrors, this.primitives = t.primitives, Bd(t.fonts), this.current = void 0, this.preview = void 0, this.view = "dashboards", this.clearHistory(), this.newDashboard = Vu(e.language); + let t = await Md(e); + this.dashboards = t.dashboards, this.widgets = t.widgets, this.widgetErrors = t.widgetErrors, this.primitives = t.primitives, Xd(t.fonts), this.layoutLimits = t.layoutLimits, this.current = void 0, this.preview = void 0, this.view = "dashboards", this.clearHistory(), this.newDashboard = Ku(e.language); } catch (e) { this.error = U(e, t.app.loadFailed); } finally { @@ -17795,12 +18928,12 @@ var _h = (e, t) => Ot(e.items, t).map((e) => e.id), vh = "opendisplay_studio.cli } } async openNewDashboard() { - this.displayDevices = await this.loadDisplayDevices(), this.newDashboard = Vu(this.language), this.newDashboardDeviceId = "", this.newDashboardOpen = !0, this.chooseNewDashboardSource(this.displayDevices.length ? "device" : "preset"); + this.displayDevices = await this.loadDisplayDevices(), this.newDashboard = Ku(this.language), this.newDashboardDeviceId = "", this.newDashboardOpen = !0, this.chooseNewDashboardSource(this.displayDevices.length ? "device" : "preset"); } async loadDisplayDevices() { if (!this.hass) return []; try { - return await kd(this.hass); + return await zd(this.hass); } catch { return []; } @@ -17810,18 +18943,18 @@ var _h = (e, t) => Ot(e.items, t).map((e) => e.id), vh = "opendisplay_studio.cli } useDevice(e) { let t = this.displayDevices.find((t) => t.id === e); - t && (this.newDashboardDeviceId = t.id, this.newDashboard = Wu(this.newDashboard, t)); + t && (this.newDashboardDeviceId = t.id, this.newDashboard = Yu(this.newDashboard, t)); } useProfile(e) { - let t = Zu.find((t) => t.id === e) ?? Zu[0]; - this.newDashboard = Gu(this.newDashboard, t); + let t = nd.find((t) => t.id === e) ?? nd[0]; + this.newDashboard = Xu(this.newDashboard, t); } async createDashboard() { if (this.hass) { this.saving = !0, this.error = ""; try { - let e = await Td(this.hass, this.newDashboard); - this.dashboards = [...this.dashboards, e], this.current = structuredClone(e), this.clearSelection(), this.dirty = !1, this.newDashboardOpen = !1, this.view = "design", this.clearHistory(), await this.composePreview(), await this.updateComplete, this.showWholeCanvas(); + let e = await Fd(this.hass, this.newDashboard); + this.dashboards = [...this.dashboards, e], this.current = structuredClone(e), this.clearSelection(), this.dirty = !1, this.newDashboardOpen = !1, this.view = "design", this.basicStep = "layout", this.clearHistory(), await this.composePreview(), await this.updateComplete, this.showWholeCanvas(); } catch (e) { this.error = U(e, t.app.createFailed); } finally { @@ -17833,7 +18966,7 @@ var _h = (e, t) => Ot(e.items, t).map((e) => e.id), vh = "opendisplay_studio.cli if (!(!this.hass || !this.current)) { this.saving = !0, this.error = ""; try { - let e = await Ed(this.hass, this.current); + let e = await Id(this.hass, this.current); this.current = structuredClone(e), this.dashboards = this.dashboards.map((t) => t.id === e.id ? e : t), this.dirty = !1; } catch (e) { this.error = U(e, t.app.saveFailed); @@ -17846,8 +18979,8 @@ var _h = (e, t) => Ot(e.items, t).map((e) => e.id), vh = "opendisplay_studio.cli if (!(!this.hass || !this.current)) { this.error = ""; try { - let e = await jd(this.hass, this.current); - hd(vd(this.current.name), JSON.stringify(e, null, 2)); + let e = await Vd(this.hass, this.current); + wd(Dd(this.current.name), JSON.stringify(e, null, 2)); } catch (e) { this.error = U(e, t.app.exportFailed); } @@ -17855,16 +18988,16 @@ var _h = (e, t) => Ot(e.items, t).map((e) => e.id), vh = "opendisplay_studio.cli } async chooseImport() { if (!this.hass || !this.current) return; - let e = await gd(".json,application/json"); + let e = await Td(".json,application/json"); if (!e) return; this.error = ""; - let n = bd(await e.text()); + let n = kd(await e.text()); if (n === void 0) { this.error = t.app.notAJsonFile; return; } try { - let e = await Md(this.hass, n, this.current.display); + let e = await Hd(this.hass, n, this.current.display); this.importing = { file: n, plan: e @@ -17879,7 +19012,7 @@ var _h = (e, t) => Ot(e.items, t).map((e) => e.id), vh = "opendisplay_studio.cli async confirmImport(e) { let { importing: n, hass: r, current: i } = this; if (!(!n || !r || !i)) try { - let { items: a } = await Md(r, n.file, i.display, e.detail.colorMap); + let { items: a } = await Hd(r, n.file, i.display, e.detail.colorMap); this.mutate((e) => { e.items = a; }), this.clearSelection(), this.importing = void 0, this.showNotice(t.app.imported(a.length)); @@ -17891,7 +19024,7 @@ var _h = (e, t) => Ot(e.items, t).map((e) => e.id), vh = "opendisplay_studio.cli if (!(!this.hass || !this.current)) { this.sending = !0, this.error = ""; try { - await Ad(this.hass, this.current), this.showNotice(t.app.sentToDevice); + await Bd(this.hass, this.current), this.showNotice(t.app.sentToDevice); } catch (e) { this.error = U(e, t.app.sendFailed); } finally { @@ -17904,7 +19037,7 @@ var _h = (e, t) => Ot(e.items, t).map((e) => e.id), vh = "opendisplay_studio.cli this.view = "design", this.preview || this.composePreview(); return; } - this.dirty && !window.confirm(t.app.discardChanges) || (this.current = structuredClone(e), this.clearSelection(), this.dirty = !1, this.clearHistory(), this.view = "design", this.composePreview().then(() => this.showWholeCanvas())); + this.dirty && !window.confirm(t.app.discardChanges) || (this.current = structuredClone(e), this.clearSelection(), this.selectedRegionId = "", this.basicStep = xf(e).length > 0 ? "widgets" : "layout", this.dirty = !1, this.clearHistory(), this.view = "design", this.composePreview().then(() => this.showWholeCanvas())); } openAction(e, t) { this.dashboardDraft = structuredClone(e), this.dashboardDialog = t; @@ -17916,7 +19049,7 @@ var _h = (e, t) => Ot(e.items, t).map((e) => e.id), vh = "opendisplay_studio.cli if (!(!this.hass || this.saving)) { this.saving = !0, this.error = ""; try { - let t = await Ed(this.hass, e); + let t = await Id(this.hass, e); return this.dashboards = this.dashboards.map((e) => e.id === t.id ? t : e), this.current?.id === t.id && (this.current = structuredClone(t), this.preview = void 0, this.dirty = !1), t; } catch (e) { this.error = U(e, t); @@ -17943,9 +19076,9 @@ var _h = (e, t) => Ot(e.items, t).map((e) => e.id), vh = "opendisplay_studio.cli if (!this.hass || this.saving) return; this.saving = !0, this.error = ""; let n = structuredClone(e); - n.id = "", n.name = Yu(e, this.dashboards, this.language), n.status = "draft", n.createdAt = "", n.updatedAt = ""; + n.id = "", n.name = ed(e, this.dashboards, this.language), n.status = "draft", n.createdAt = "", n.updatedAt = ""; try { - let e = await Td(this.hass, n); + let e = await Fd(this.hass, n); this.dashboards = [...this.dashboards, e]; } catch (e) { this.error = U(e, t.app.duplicateFailed); @@ -17954,7 +19087,7 @@ var _h = (e, t) => Ot(e.items, t).map((e) => e.id), vh = "opendisplay_studio.cli } } async saveSettings() { - if (this.dashboardDialog !== "settings" || !this.dashboardDraft || !qu(this.dashboardDraft)) return; + if (this.dashboardDialog !== "settings" || !this.dashboardDraft || !Qu(this.dashboardDraft)) return; let e = structuredClone(this.dashboardDraft); e.name = e.name.trim(), await this.updateFromGallery(e, t.app.settingsFailed) && this.closeAction(); } @@ -17963,7 +19096,7 @@ var _h = (e, t) => Ot(e.items, t).map((e) => e.id), vh = "opendisplay_studio.cli let e = this.dashboardDraft.id; this.saving = !0, this.error = ""; try { - await Dd(this.hass, e), this.dashboards = this.dashboards.filter((t) => t.id !== e), this.current?.id === e && (this.current = void 0, this.clearSelection(), this.preview = void 0, this.dirty = !1, this.clearHistory()), this.closeAction(); + await Ld(this.hass, e), this.dashboards = this.dashboards.filter((t) => t.id !== e), this.current?.id === e && (this.current = void 0, this.clearSelection(), this.preview = void 0, this.dirty = !1, this.clearHistory()), this.closeAction(); } catch (e) { this.error = U(e, t.app.deleteFailed); } finally { @@ -18000,7 +19133,8 @@ var _h = (e, t) => Ot(e.items, t).map((e) => e.id), vh = "opendisplay_studio.cli canEnter: a ? Tt(a) : !1, entered: this.enteredGroupId !== "", canPaste: (this.clipboard?.items.length ?? 0) > 0, - dirty: this.dirty + dirty: this.dirty, + basic: this.isBasic }; } get selectedItems() { @@ -18011,7 +19145,7 @@ var _h = (e, t) => Ot(e.items, t).map((e) => e.id), vh = "opendisplay_studio.cli return this.selectedItems.at(-1); } runCommand(e, t) { - let n = wu(e), r = this.commandContext(t); + let n = Ou(e), r = this.commandContext(t); n.isEnabled(r) && n.run(r, this.commandActions); } onCommand(e) { @@ -18020,11 +19154,11 @@ var _h = (e, t) => Ot(e.items, t).map((e) => e.id), vh = "opendisplay_studio.cli } copy(e) { let t = this.current; - t && (this.clipboard = _n(t, e), Th(this.clipboard)); + t && (this.clipboard = _n(t, e), Gg(this.clipboard)); } cut(e) { this.copy(e), this.mutate((t) => { - for (let n of e) Il(t, n); + for (let n of e) Rl(t, n); }), this.setSelection(this.selection.filter((t) => !e.includes(t))); } paste() { @@ -18040,7 +19174,7 @@ var _h = (e, t) => Ot(e.items, t).map((e) => e.id), vh = "opendisplay_studio.cli this.pasteWith(() => ({ anchor: e }), n ? { parentId: n.id } : {}); } pasteWith(e, t) { - let n = this.current, r = this.clipboard ?? Ch(); + let n = this.current, r = this.clipboard ?? Ug(); if (!n || !r) return; let i = []; this.mutate((n) => { @@ -18067,13 +19201,13 @@ var _h = (e, t) => Ot(e.items, t).map((e) => e.id), vh = "opendisplay_studio.cli }; } applyArrangement(e) { - e.length !== 0 && this.mutate((t) => lu(t, e)); + e.length !== 0 && this.mutate((t) => du(t, e)); } align(e, t) { - this.current && this.applyArrangement(ou(this.current, e, t, this.arrangeOptions)); + this.current && this.applyArrangement(cu(this.current, e, t, this.arrangeOptions)); } distribute(e, t) { - this.current && this.applyArrangement(cu(this.current, e, t, this.arrangeOptions)); + this.current && this.applyArrangement(uu(this.current, e, t, this.arrangeOptions)); } onAlignTargetChange(e) { this.alignTo = e.detail.target; @@ -18081,7 +19215,7 @@ var _h = (e, t) => Ot(e.items, t).map((e) => e.id), vh = "opendisplay_studio.cli nudge(e, t, n) { let r = this.current; if (!r) return; - let { dx: i, dy: a } = Oh(t, n ? r.display.snapSize : 1), o = Date.now() - this.lastNudgeAt > Eh; + let { dx: i, dy: a } = Jg(t, n ? r.display.snapSize : 1), o = Date.now() - this.lastNudgeAt > Kg; this.lastNudgeAt = Date.now(), this.mutate((t) => { wn(t, e, i, a, pt(t), (e) => xs(e, this.primitives).position.length === 0); }, !0, o); @@ -18097,13 +19231,13 @@ var _h = (e, t) => Ot(e.items, t).map((e) => e.id), vh = "opendisplay_studio.cli this.menu = void 0; } menuLayout(e) { - return e === "empty" ? Nu : e === "tree" ? Mu : ju; + return e === "empty" ? Lu : e === "tree" ? Iu : Fu; } onContextMenu(e) { let { source: n, itemId: r, clientX: i, clientY: a, point: o } = e.detail, s = this.current; if (!s) return; r && !this.selection.includes(r) && this.selectItem(r), !r && n === "empty" && this.clearSelection(); - let c = r ? T(s.items, r) : void 0, l = c ? this.targetsForMenu(c) : [], u = this.commandContext(c, l), d = Pu(this.menuLayout(n), u, pd()); + let c = r ? T(s.items, r) : void 0, l = c ? this.targetsForMenu(c) : [], u = this.commandContext(c, l), d = Ru(this.menuLayout(n), u, Sd()); d.length !== 0 && (this.menu = { ...this.menuPosition(i, a, d), label: c?.name ?? t.canvas.menuLabel, @@ -18116,18 +19250,18 @@ var _h = (e, t) => Ot(e.items, t).map((e) => e.id), vh = "opendisplay_studio.cli return this.selection.includes(e.id) ? this.selectedItems : [e]; } menuPosition(e, t, n) { - let r = this.getBoundingClientRect(), i = n.length * bh + n.filter((e) => e.separatorBefore).length * xh + 16, a = e - r.left, o = t - r.top; + let r = this.getBoundingClientRect(), i = n.length * Bg + n.filter((e) => e.separatorBefore).length * Vg + 16, a = e - r.left, o = t - r.top; return { - x: a + yh + Sh > r.width ? Math.max(Sh, a - yh) : a, - y: o + i + Sh > r.height ? Math.max(Sh, r.height - i - Sh) : o + x: a + zg + Hg > r.width ? Math.max(Hg, a - zg) : a, + y: o + i + Hg > r.height ? Math.max(Hg, r.height - i - Hg) : o }; } onMenuSelect(e) { let t = this.menu; if (this.closeMenu(), !t || !this.current) return; let n = t.itemId ? T(this.current.items, t.itemId) : void 0; - if (!Cu(e.detail.id)) return; - let r = wu(e.detail.id), i = this.commandContext(n, n ? this.targetsForMenu(n) : []); + if (!Du(e.detail.id)) return; + let r = Ou(e.detail.id), i = this.commandContext(n, n ? this.targetsForMenu(n) : []); r.isEnabled(i) && (this.menu = t, r.run(i, this.commandActions), this.menu = void 0); } renderMenu() { @@ -18166,7 +19300,7 @@ var _h = (e, t) => Ot(e.items, t).map((e) => e.id), vh = "opendisplay_studio.cli ` : I; } schedulePreview() { - this.previewTimer && window.clearTimeout(this.previewTimer), this.previewTimer = window.setTimeout(() => void this.composePreview(), kh); + this.previewTimer && window.clearTimeout(this.previewTimer), this.previewTimer = window.setTimeout(() => void this.composePreview(), Yg); } refreshOnStateChange(e) { let t = this.preview?.dependencies; @@ -18180,7 +19314,7 @@ var _h = (e, t) => Ot(e.items, t).map((e) => e.id), vh = "opendisplay_studio.cli this.error = ""; let e = ++this.previewRequest, n = structuredClone(this.current); try { - let t = await Od(this.hass, n); + let t = await Rd(this.hass, n); e === this.previewRequest && (this.preview = { ...t, composedFrom: n @@ -18193,10 +19327,14 @@ var _h = (e, t) => Ot(e.items, t).map((e) => e.id), vh = "opendisplay_studio.cli this.view = e, e === "code" && !this.preview && this.composePreview(); } selectItem(e) { - this.setSelection(e ? [e] : []); + if (!e) { + this.clearSelection(); + return; + } + this.setSelection([e]); } clearSelection() { - this.setSelection([]); + this.setSelection([]), this.selectedRegionId = ""; } setSelection(e) { e.length === this.selection.length && e.every((e, t) => e === this.selection[t]) || (this.selection = e); @@ -18209,7 +19347,7 @@ var _h = (e, t) => Ot(e.items, t).map((e) => e.id), vh = "opendisplay_studio.cli } isInsideEntered(e, t) { let n = this.enteredGroupId; - return !n || [t, ..._h(e, t)].includes(n) || _h(e, n).includes(t); + return !n || [t, ...Lg(e, t)].includes(n) || Lg(e, n).includes(t); } showWholeCanvas() { this.canvas?.resetView(), requestAnimationFrame(() => this.canvas?.fitView()); @@ -18220,14 +19358,14 @@ var _h = (e, t) => Ot(e.items, t).map((e) => e.id), vh = "opendisplay_studio.cli if (a === "container") return Zt(i, n, r); if (a === "widget") { let e = this.widgets.find((e) => e.id === o); - return e ? Wl(e, n, r, i) : void 0; + return e ? Kl(e, n, r, i) : void 0; } - if (o === Dh && this.selectExistingGrid(i)) return; - let s = Gl(this.primitives, o, n, r, i); + if (o === qg && this.selectExistingGrid(i)) return; + let s = ql(this.primitives, o, n, r, i); return s || (this.error = t.app.unsupportedPrimitive(o)), s; } selectExistingGrid(e) { - let t = E(e.items).find((e) => e.kind === "primitive" && e.primitive.type === Dh); + let t = E(e.items).find((e) => e.kind === "primitive" && e.primitive.type === qg); return t ? (this.selectItem(t.id), !0) : !1; } addAt(e, t, n, r) { @@ -18238,15 +19376,90 @@ var _h = (e, t) => Ot(e.items, t).map((e) => e.id), vh = "opendisplay_studio.cli } addFromCatalog(e) { if (!this.current) return; - let { x: t, y: n } = Kl(this.current, this.snapEnabled); + if (this.isBasic) { + this.addToRegion(e, this.selectedRegionId); + return; + } + let { x: t, y: n } = Jl(this.current, this.snapEnabled); this.addAt(e, t, n); } dropFromCatalog(e, t, n) { let r = this.current, i = this.canvas?.displayPointAt(t, n); if (!r || !i) return; + if (this.isBasic) { + this.addToRegion(e, this.regionAtPoint(i)); + return; + } let a = pt(r), o = _(mt(i.x, r, this.snapEnabled), a.x, a.x + a.width - 1), s = _(mt(i.y, r, this.snapEnabled), a.y, a.y + a.height - 1), c = Vt(r.items, o, s, [], this.enteredGroupId || void 0); this.addAt(e, o, s, c?.id); } + get isBasic() { + return this.current?.mode === "basic"; + } + regionAtPoint(e) { + return this.layoutFrames ? yf(this.layoutFrames, e) ?? "" : ""; + } + async refreshLayoutFrames() { + let e = this.current, n = e?.layout; + if (!this.hass || !e || !n) { + this.layoutFrames = void 0, this.layoutKey = ""; + return; + } + let { width: r, height: i, padding: a } = e.display, o = JSON.stringify([ + r, + i, + a, + n + ]); + if (o === this.layoutKey) return; + this.layoutKey = o; + let s = this.layoutRequest += 1; + try { + let t = await Nd(this.hass, e.display, n); + s === this.layoutRequest && (this.layoutFrames = t); + } catch (e) { + this.error = U(e, t.app.previewFailed); + } + } + addToRegion(e, n) { + let r = this.current, i = r?.layout, [a, o] = e.split(":"), s = this.widgets.find((e) => e.id === o); + if (!r || !i || a !== "widget" || !s) return; + let c = wf(r, i, n), l = this.layoutFrames?.regions.find((e) => e.id === c?.id); + if (!c || !l) { + this.showNotice(t.basic.noEmptyRegion); + return; + } + this.mutate((e) => Tf(e, s, c, l)), this.selectRegion(c.id), this.composePreview(); + } + selectRegion(e) { + let t = this.current && Sf(this.current, e); + this.setSelection(t ? [t.id] : []), this.selectedRegionId = e; + } + onBasicStepChange(e) { + this.basicStep = e.detail.step; + } + onRegionSelect(e) { + this.selectRegion(e.detail.regionId); + } + onRegionsMerge(e) { + let { rectangle: t } = e.detail; + this.mutate((e) => Of(e, t)); + let n = this.current?.layout, r = n && sf(n, t); + this.selectRegion(r?.id ?? ""), this.composePreview(); + } + onRegionSplit(e) { + this.mutate((t) => kf(t, e.detail.regionId)), this.selectRegion(e.detail.regionId); + } + onGridChange(e) { + let { columns: t, rows: n, gap: r } = e.detail; + this.mutate((e) => { + let i = e.layout; + i && (r !== void 0 && jf(e, r), Af(e, { + columns: t ?? i.columns, + rows: n ?? i.rows + })); + }), this.current && this.keepExistingSelection(this.current), this.composePreview(); + } groupSelection(e) { let t; this.mutate((n) => { @@ -18269,7 +19482,7 @@ var _h = (e, t) => Ot(e.items, t).map((e) => e.id), vh = "opendisplay_studio.cli confirmDeleteItem() { let e = this.pendingDeleteIds; e.length !== 0 && (this.pendingDeleteIds = [], this.mutate((t) => { - for (let n of e) Il(t, n); + for (let n of e) Rl(t, n); }), this.setSelection(this.selection.filter((t) => !e.includes(t)))); } onDashboardOpen(e) { @@ -18290,10 +19503,14 @@ var _h = (e, t) => Ot(e.items, t).map((e) => e.id), vh = "opendisplay_studio.cli }; } onSettingsChange(e) { - this.dashboardDraft &&= Ku(this.dashboardDraft, e.detail.value); + this.dashboardDraft &&= Zu(this.dashboardDraft, e.detail.value); } onNewDashboardChange(e) { - this.newDashboard = Ku(this.newDashboard, e.detail.value); + this.newDashboard = Zu(this.newDashboard, e.detail.value); + } + onNewDashboardMode(e) { + let t = this.layoutLimits?.defaultGap ?? 8; + this.newDashboard = bf(this.newDashboard, e.detail.mode, t); } onNewDashboardSource(e) { this.chooseNewDashboardSource(e.detail.source); @@ -18307,6 +19524,9 @@ var _h = (e, t) => Ot(e.items, t).map((e) => e.id), vh = "opendisplay_studio.cli closeNewDashboard() { this.newDashboardOpen = !1; } + onRenameHandled() { + this.renameRequestId = ""; + } showGallery() { this.view = "dashboards"; } @@ -18343,7 +19563,7 @@ var _h = (e, t) => Ot(e.items, t).map((e) => e.id), vh = "opendisplay_studio.cli } onItemRename(e) { let { itemId: t, name: n } = e.detail; - this.mutate((e) => Rl(e, t, n)); + this.mutate((e) => Bl(e, t, n)); } cancelDeleteItem() { this.pendingDeleteIds = []; @@ -18394,22 +19614,22 @@ var _h = (e, t) => Ot(e.items, t).map((e) => e.id), vh = "opendisplay_studio.cli } onLayersReorder(e) { let { itemId: t, targetId: n, edge: r } = e.detail; - this.mutate((e) => Ll(e, t, n, r)); + this.mutate((e) => zl(e, t, n, r)); } onItemNumberChange(e) { let { key: t, value: n } = e.detail; - this.mutate((e) => Al(e, this.selectedItemId, t, n, this.primitives)); + this.mutate((e) => Ml(e, this.selectedItemId, t, n, this.primitives)); } onExpressionChange(e) { let { key: t, template: n } = e.detail; - this.mutate((e) => Jl(e, this.selectedItemId, t, n)); + this.mutate((e) => Xl(e, this.selectedItemId, t, n)); } onDisplayNumberChange(e) { let { key: t, value: n } = e.detail; - this.mutate((e) => Nl(e, t, n)); + this.mutate((e) => Fl(e, t, n)); } onDisplayBackgroundChange(e) { - this.mutate((t) => Pl(t, e.detail.color)); + this.mutate((t) => Il(t, e.detail.color)); } onWidgetOptionsChange(e) { let t = this.selectedItem; @@ -18417,15 +19637,15 @@ var _h = (e, t) => Ot(e.items, t).map((e) => e.id), vh = "opendisplay_studio.cli let n = this.widgets.find((e) => e.id === t.widget.type); if (!n) return; let r = ec(e.detail.value, n); - this.mutate((e) => zl(e, t.id, r)); + this.mutate((e) => Vl(e, t.id, r)); } onWidgetPicksChange(e) { let { sourceKey: t, picks: n } = e.detail; - this.mutate((e) => Bl(e, this.selectedItemId, t, n)); + this.mutate((e) => Hl(e, this.selectedItemId, t, n)); } async reloadWidgets() { if (this.hass) try { - let e = await wd(this.hass); + let e = await Pd(this.hass); this.widgets = e.widgets, this.widgetErrors = e.widgetErrors, this.showNotice(t.library.widgetsReloaded(e.widgets.length, e.widgetErrors.length)), this.composePreview(); } catch (e) { this.error = U(e, t.library.reloadFailed); @@ -18434,7 +19654,7 @@ var _h = (e, t) => Ot(e.items, t).map((e) => e.id), vh = "opendisplay_studio.cli showNotice(e) { this.notice = e, this.noticeTimer && window.clearTimeout(this.noticeTimer), this.noticeTimer = window.setTimeout(() => { this.notice = ""; - }, Ah); + }, Xg); } onContainerBackgroundChange(e) { let t = hn(e.detail.value); @@ -18442,19 +19662,19 @@ var _h = (e, t) => Ot(e.items, t).map((e) => e.id), vh = "opendisplay_studio.cli } onAlignInParent(e) { let t = this.selectedItemId, n = this.preview?.itemBounds[t]; - this.mutate((r) => Kd(r, t, e.detail.place, n)); + this.mutate((r) => Ff(r, t, e.detail.place, n)); } onPrimitiveFieldChange(e) { let { key: t, value: n } = e.detail, r = this.selectedItemId, i = this.preview?.itemBounds[r]; - this.mutate((e) => Vl(e, r, t, n, i)); + this.mutate((e) => Ul(e, r, t, n, i)); } onPrimitiveFieldsReset(e) { let { values: t } = e.detail; - this.mutate((e) => Hl(e, this.selectedItemId, t)); + this.mutate((e) => Wl(e, this.selectedItemId, t)); } onPrimitiveChange(e) { let { value: t } = e.detail; - this.mutate((e) => Ul(e, this.selectedItemId, t, this.primitives)); + this.mutate((e) => Gl(e, this.selectedItemId, t, this.primitives)); } renderDeleteDialog() { let e = this.pendingDeleteIds.flatMap((e) => T(this.current?.items ?? [], e) ?? []), [n] = e; @@ -18496,6 +19716,7 @@ var _h = (e, t) => Ot(e.items, t).map((e) => e.id), vh = "opendisplay_studio.cli .saving=${this.saving} @new-dashboard-change=${this.onNewDashboardChange} @new-dashboard-source=${this.onNewDashboardSource} + @new-dashboard-mode=${this.onNewDashboardMode} @new-dashboard-device=${this.onNewDashboardDevice} @new-dashboard-profile=${this.onNewDashboardProfile} @new-dashboard-close=${this.closeNewDashboard} @@ -18537,90 +19758,151 @@ var _h = (e, t) => Ot(e.items, t).map((e) => e.id), vh = "opendisplay_studio.cli @settings-close=${this.showGallery} @widgets-reload=${this.reloadWidgets} > + `; + } + layoutStyle() { + return V({ + "--toolbox-width": this.leftCollapsed ? "48px" : "255px", + "--inspector-width": this.rightCollapsed ? "48px" : `${this.inspectorWidth}px` + }); + } + renderLibrary(e) { + return F` + + `; + } + renderCanvas(e) { + return F` + 0} + .canRedo=${this.redoCount > 0} + .viewport=${this.viewport} + .layoutFrames=${this.layoutFrames} + .basicStep=${this.basicStep} + .selectedRegionId=${this.selectedRegionId} + @viewport-change=${this.onViewportChange} + @items-transform=${this.onItemsTransform} + @item-transform-end=${this.onItemTransformEnd} + @gesture-cancel=${this.onGestureCancel} + @primitive-field-change=${this.onPrimitiveFieldChange} + @snap-toggle=${this.toggleSnap} + @align-target-change=${this.onAlignTargetChange} + @region-select=${this.onRegionSelect} + @regions-merge=${this.onRegionsMerge} + @region-split=${this.onRegionSplit} + > + `; + } + renderInspector(e) { + return F` + + `; + } + renderDialogs() { + return F` + ${this.renderDeleteDialog()} ${this.renderImportDialog()} + ${this.renderMenu()} ${this.renderShortcutsDialog()} `; } renderDesign(e) { return F`
{ - this.renameRequestId = ""; - }} + @rename-handled=${this.onRenameHandled} @command=${this.onCommand} > - - 0} - .canRedo=${this.redoCount > 0} - .viewport=${this.viewport} - @viewport-change=${this.onViewportChange} - @items-transform=${this.onItemsTransform} - @item-transform-end=${this.onItemTransformEnd} - @gesture-cancel=${this.onGestureCancel} - @primitive-field-change=${this.onPrimitiveFieldChange} - @snap-toggle=${this.toggleSnap} - @align-target-change=${this.onAlignTargetChange} - > - + ${this.renderLibrary(!1)} ${this.renderCanvas(e)} + ${this.renderInspector(e)}
- ${this.renderDeleteDialog()} ${this.renderImportDialog()} - ${this.renderMenu()} ${this.renderShortcutsDialog()} + ${this.renderDialogs()} + `; + } + renderBasicLeft(e) { + return F` +
+ + ${this.basicStep === "layout" ? F` + + ` : this.renderLibrary(!0)} +
+ `; + } + renderBasicDesign(e) { + return F` +
+ ${this.renderBasicLeft(e)} ${this.renderCanvas(e)} + ${this.renderInspector(e)} +
+ ${this.renderDialogs()} `; } renderError() { @@ -18633,6 +19915,9 @@ var _h = (e, t) => Ot(e.items, t).map((e) => e.id), vh = "opendisplay_studio.cli ${this.notice} ` : I; } + renderEditorBody(e) { + return e.mode === "basic" ? this.renderBasicDesign(e) : this.renderDesign(e); + } renderEditor(e) { return F`
@@ -18657,7 +19942,7 @@ var _h = (e, t) => Ot(e.items, t).map((e) => e.id), vh = "opendisplay_studio.cli ${this.renderError()} ${this.renderNotice()} ${this.view === "code" ? F` - ` : this.renderDesign(e)} + ` : this.renderEditorBody(e)}
`; } @@ -18672,6 +19957,6 @@ var _h = (e, t) => Ot(e.items, t).map((e) => e.id), vh = "opendisplay_studio.cli return this.view === "dashboards" || !e ? this.renderGallery() : this.renderEditor(e); } }; -Y([z({ attribute: !1 })], $.prototype, "hass", void 0), Y([B()], $.prototype, "dashboards", void 0), Y([B()], $.prototype, "view", void 0), Y([B()], $.prototype, "widgets", void 0), Y([B()], $.prototype, "widgetErrors", void 0), Y([B()], $.prototype, "notice", void 0), Y([B()], $.prototype, "sending", void 0), Y([B()], $.prototype, "importing", void 0), Y([B()], $.prototype, "primitives", void 0), Y([B()], $.prototype, "current", void 0), Y([B()], $.prototype, "selection", void 0), Y([B()], $.prototype, "enteredGroupId", void 0), Y([B()], $.prototype, "menu", void 0), Y([B()], $.prototype, "shortcutsOpen", void 0), Y([B()], $.prototype, "renameRequestId", void 0), Y([B()], $.prototype, "preview", void 0), Y([B()], $.prototype, "loading", void 0), Y([B()], $.prototype, "saving", void 0), Y([B()], $.prototype, "dirty", void 0), Y([B()], $.prototype, "error", void 0), Y([B()], $.prototype, "draggingCatalog", void 0), Y([B()], $.prototype, "undoCount", void 0), Y([B()], $.prototype, "redoCount", void 0), Y([B()], $.prototype, "pendingDeleteIds", void 0), Y([B()], $.prototype, "leftCollapsed", void 0), Y([B()], $.prototype, "rightCollapsed", void 0), Y([B()], $.prototype, "inspectorWidth", void 0), Y([B()], $.prototype, "snapEnabled", void 0), Y([B()], $.prototype, "alignTo", void 0), Y([B()], $.prototype, "viewport", void 0), Y([B()], $.prototype, "newDashboardOpen", void 0), Y([B()], $.prototype, "newDashboard", void 0), Y([B()], $.prototype, "newDashboardSource", void 0), Y([B()], $.prototype, "newDashboardDeviceId", void 0), Y([B()], $.prototype, "displayDevices", void 0), Y([B()], $.prototype, "dashboardDialog", void 0), Y([B()], $.prototype, "dashboardDraft", void 0), Y([bl("ods-canvas")], $.prototype, "canvas", void 0), $ = Y([R("ods-app")], $); +Y([z({ attribute: !1 })], $.prototype, "hass", void 0), Y([B()], $.prototype, "dashboards", void 0), Y([B()], $.prototype, "view", void 0), Y([B()], $.prototype, "widgets", void 0), Y([B()], $.prototype, "widgetErrors", void 0), Y([B()], $.prototype, "notice", void 0), Y([B()], $.prototype, "sending", void 0), Y([B()], $.prototype, "importing", void 0), Y([B()], $.prototype, "primitives", void 0), Y([B()], $.prototype, "current", void 0), Y([B()], $.prototype, "selection", void 0), Y([B()], $.prototype, "enteredGroupId", void 0), Y([B()], $.prototype, "menu", void 0), Y([B()], $.prototype, "shortcutsOpen", void 0), Y([B()], $.prototype, "renameRequestId", void 0), Y([B()], $.prototype, "preview", void 0), Y([B()], $.prototype, "loading", void 0), Y([B()], $.prototype, "saving", void 0), Y([B()], $.prototype, "dirty", void 0), Y([B()], $.prototype, "error", void 0), Y([B()], $.prototype, "draggingCatalog", void 0), Y([B()], $.prototype, "undoCount", void 0), Y([B()], $.prototype, "redoCount", void 0), Y([B()], $.prototype, "pendingDeleteIds", void 0), Y([B()], $.prototype, "leftCollapsed", void 0), Y([B()], $.prototype, "rightCollapsed", void 0), Y([B()], $.prototype, "inspectorWidth", void 0), Y([B()], $.prototype, "snapEnabled", void 0), Y([B()], $.prototype, "alignTo", void 0), Y([B()], $.prototype, "viewport", void 0), Y([B()], $.prototype, "newDashboardOpen", void 0), Y([B()], $.prototype, "newDashboard", void 0), Y([B()], $.prototype, "newDashboardSource", void 0), Y([B()], $.prototype, "newDashboardDeviceId", void 0), Y([B()], $.prototype, "displayDevices", void 0), Y([B()], $.prototype, "dashboardDialog", void 0), Y([B()], $.prototype, "dashboardDraft", void 0), Y([B()], $.prototype, "layoutLimits", void 0), Y([B()], $.prototype, "layoutFrames", void 0), Y([B()], $.prototype, "basicStep", void 0), Y([B()], $.prototype, "selectedRegionId", void 0), Y([Sl("ods-canvas")], $.prototype, "canvas", void 0), $ = Y([R("ods-app")], $); //#endregion export { $ as OdsApp }; diff --git a/custom_components/opendisplay_studio/items.py b/custom_components/opendisplay_studio/items.py index 6a33f92..faa267d 100644 --- a/custom_components/opendisplay_studio/items.py +++ b/custom_components/opendisplay_studio/items.py @@ -157,6 +157,9 @@ def _validate_widget( "padding": integer(layout.get("padding", 0), "layout.padding", 0, 128) }, } + if "region" in value: + # The region of a basic dashboard the widget fills; its frame follows from it. + item["region"] = string(value["region"], "item.region", 64) _with_expressions( item, _expressions(value, ITEM_EXPRESSIONS, single=_single(relative=relative)) ) diff --git a/custom_components/opendisplay_studio/manifest.json b/custom_components/opendisplay_studio/manifest.json index f6ebb18..0b0927d 100644 --- a/custom_components/opendisplay_studio/manifest.json +++ b/custom_components/opendisplay_studio/manifest.json @@ -11,5 +11,5 @@ "issue_tracker": "https://github.com/Misiu/OpenDisplay-Studio-Integration/issues", "requirements": ["odl-layout==0.1.1", "odl-renderer>=0.5.13"], "single_config_entry": true, - "version": "0.2.0" + "version": "0.3.0" } diff --git a/custom_components/opendisplay_studio/media_source.py b/custom_components/opendisplay_studio/media_source.py index 0adeec0..cc20013 100644 --- a/custom_components/opendisplay_studio/media_source.py +++ b/custom_components/opendisplay_studio/media_source.py @@ -42,8 +42,12 @@ async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: if dashboard is None or dashboard["status"] != "ready": raise Unresolvable("Unknown or Draft OpenDisplay Studio dashboard") try: + # The picture is turned for the display unless the dashboard asks for it as + # designed, which a person looking at it, or a card, finds easier to read. rendered = await async_render_dashboard( - self.hass, dashboard, for_device=True + self.hass, + dashboard, + for_device=not dashboard["display"].get("mediaAsDesigned", False), ) except (DashboardCompileError, OdlRenderError) as err: LOGGER.error("Could not render %s: %s", item.identifier, err) diff --git a/custom_components/opendisplay_studio/rendering.py b/custom_components/opendisplay_studio/rendering.py index 2223c31..5d6f4f9 100644 --- a/custom_components/opendisplay_studio/rendering.py +++ b/custom_components/opendisplay_studio/rendering.py @@ -55,12 +55,21 @@ def _with_clear_margin(image: Image.Image, margin: int) -> Image.Image: return rgb -def _encode_png(image: Image.Image, rotation: int, margin: int = 0) -> bytes: +def _without_margin(image: Image.Image, margin: int) -> Image.Image: + """Return the display itself: the picture with the margin around it cut off.""" + return image.crop((margin, margin, image.width - margin, image.height - margin)) + + +def _encode_png( + image: Image.Image, rotation: int, margin: int = 0, *, show_margin: bool = False +) -> bytes: + if margin and not show_margin: + image = _without_margin(image, margin) if rotation: # PIL turns counter-clockwise; the dashboard setting is clockwise. image = image.rotate(-rotation, expand=True) output = BytesIO() - if margin: + if margin and show_margin: _with_clear_margin(image, margin).save(output, format="PNG", optimize=False) else: image.convert("RGB").save(output, format="PNG", optimize=False) @@ -93,8 +102,14 @@ async def async_render( # noqa: PLR0913 history: DataProvider | None = None, rotation: int = 0, margin: int = 0, + show_margin: bool = False, ) -> OdlRenderResult: - """Render ODL, check the size, then turn the picture clockwise by `rotation`.""" + """ + Render ODL, check the size, then turn the picture clockwise by `rotation`. + + `width` and `height` include a `margin` drawn around the display. It is cut + off the PNG, unless `show_margin` keeps it for the editor to look at. + """ requested_at = monotonic() async with self._semaphore: started_at = monotonic() @@ -118,7 +133,9 @@ async def async_render( # noqa: PLR0913 f"expected {width}x{height}" ) raise OdlRenderError(message) - png = await asyncio.to_thread(_encode_png, image, rotation, margin) + png = await asyncio.to_thread( + _encode_png, image, rotation, margin, show_margin=show_margin + ) completed_at = monotonic() return OdlRenderResult( png=png, diff --git a/custom_components/opendisplay_studio/sdk/layout.py b/custom_components/opendisplay_studio/sdk/layout.py index c4d6ec3..a522314 100644 --- a/custom_components/opendisplay_studio/sdk/layout.py +++ b/custom_components/opendisplay_studio/sdk/layout.py @@ -13,13 +13,15 @@ def _split(total: int, weights: Sequence[int], gap: int) -> list[tuple[int, int] available = max(count, total - gap * (count - 1)) weight_sum = sum(weights) spans: list[tuple[int, int]] = [] - offset = 0 used_weight = 0 - for weight in weights: + start = 0 + for index, weight in enumerate(weights): used_weight += weight + # The shares are cut from what is left after the gaps, so the last one ends + # exactly where `total` does. end = round(available * used_weight / weight_sum) - spans.append((offset, max(1, end - offset))) - offset = end + gap + spans.append((start + gap * index, max(1, end - start))) + start = end return spans diff --git a/custom_components/opendisplay_studio/websocket.py b/custom_components/opendisplay_studio/websocket.py index 241d57c..ebe8aee 100644 --- a/custom_components/opendisplay_studio/websocket.py +++ b/custom_components/opendisplay_studio/websocket.py @@ -9,6 +9,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceNotFound +from .basic_layout import LAYOUT_LIMITS from .compiler import DashboardCompileError from .const import DOMAIN, INTEGRATION_VERSION, LOGGER, RENDER_HTTP_PATH from .dashboard_files import DashboardFileError, export_file @@ -20,6 +21,7 @@ from .palette import PALETTE_COLORS from .primitives import DEFAULT_PRIMITIVES from .rendering import OdlRenderError, OdlRenderService +from .websocket_basic import async_register_basic_commands from .websocket_fonts import async_describe_fonts, async_register_font_commands from .widget_reload import async_reload_widgets from .widgets import WidgetRegistry @@ -69,6 +71,7 @@ async def websocket_bootstrap( "widgetErrors": [error.as_dict() for error in _widgets(hass).errors], "primitives": DEFAULT_PRIMITIVES.localized(msg["language"]), "fonts": await async_describe_fonts(hass), + "layoutLimits": LAYOUT_LIMITS, }, ) @@ -350,6 +353,7 @@ def websocket_prepare_import( def async_register_commands(hass: HomeAssistant) -> None: async_register_font_commands(hass) + async_register_basic_commands(hass) websocket_api.async_register_command(hass, websocket_export_dashboard) websocket_api.async_register_command(hass, websocket_prepare_import) websocket_api.async_register_command(hass, websocket_list_icons) diff --git a/custom_components/opendisplay_studio/websocket_basic.py b/custom_components/opendisplay_studio/websocket_basic.py new file mode 100644 index 0000000..f544045 --- /dev/null +++ b/custom_components/opendisplay_studio/websocket_basic.py @@ -0,0 +1,62 @@ +"""The WebSocket command that gives the panel the geometry of a basic layout.""" + +from __future__ import annotations + +from typing import Any + +import voluptuous as vol +from homeassistant.components import websocket_api +from homeassistant.core import HomeAssistant + +from .basic_layout import grid_cells, region_frame, validate_layout, working_area +from .validation import DashboardValidationError + + +@websocket_api.websocket_command( + { + vol.Required("type"): "opendisplay_studio/layout_frames", + vol.Required("width"): vol.All(int, vol.Range(min=64, max=4096)), + vol.Required("height"): vol.All(int, vol.Range(min=64, max=4096)), + vol.Required("padding"): vol.All(int, vol.Range(min=0, max=1024)), + vol.Required("layout"): dict, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_layout_frames( + _hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """ + Return where the cells and the regions of a layout are on the display. + + The panel draws the layout from these boxes and never lays a grid out itself, so + what it shows is what the renderer will draw. + """ + try: + layout = validate_layout(msg["layout"]) + except DashboardValidationError as err: + connection.send_error(msg["id"], "invalid_layout", str(err)) + return + area = working_area(msg["width"], msg["height"], msg["padding"]) + columns = layout["columns"] + cells = grid_cells(area, columns, layout["rows"], layout["gap"]) + connection.send_result( + msg["id"], + { + "cells": [ + {"x": cell.x, "y": cell.y, "width": cell.width, "height": cell.height} + for cell in cells + ], + "regions": [ + {"id": region["id"], **region_frame(cells, columns, region)} + for region in layout["regions"] + ], + }, + ) + + +def async_register_basic_commands(hass: HomeAssistant) -> None: + """Register the commands of the basic editor.""" + websocket_api.async_register_command(hass, websocket_layout_frames) diff --git a/custom_components/opendisplay_studio/widgets/weather/preview.png b/custom_components/opendisplay_studio/widgets/weather/preview.png index 388dd3f..20a6de2 100644 Binary files a/custom_components/opendisplay_studio/widgets/weather/preview.png and b/custom_components/opendisplay_studio/widgets/weather/preview.png differ diff --git a/docs/images/basic-gallery.webp b/docs/images/basic-gallery.webp new file mode 100644 index 0000000..32d5dc5 Binary files /dev/null and b/docs/images/basic-gallery.webp differ diff --git a/docs/images/basic-grid.webp b/docs/images/basic-grid.webp new file mode 100644 index 0000000..8238236 Binary files /dev/null and b/docs/images/basic-grid.webp differ diff --git a/docs/images/basic-join.webp b/docs/images/basic-join.webp new file mode 100644 index 0000000..e09210e Binary files /dev/null and b/docs/images/basic-join.webp differ diff --git a/docs/images/basic-many-regions.webp b/docs/images/basic-many-regions.webp new file mode 100644 index 0000000..637b468 Binary files /dev/null and b/docs/images/basic-many-regions.webp differ diff --git a/docs/images/basic-new-dashboard.webp b/docs/images/basic-new-dashboard.webp new file mode 100644 index 0000000..a3261c5 Binary files /dev/null and b/docs/images/basic-new-dashboard.webp differ diff --git a/docs/images/basic-regions.webp b/docs/images/basic-regions.webp new file mode 100644 index 0000000..09ba9bb Binary files /dev/null and b/docs/images/basic-regions.webp differ diff --git a/docs/images/basic-result.webp b/docs/images/basic-result.webp new file mode 100644 index 0000000..3594d0c Binary files /dev/null and b/docs/images/basic-result.webp differ diff --git a/docs/images/basic-widgets-empty.webp b/docs/images/basic-widgets-empty.webp new file mode 100644 index 0000000..76e25e5 Binary files /dev/null and b/docs/images/basic-widgets-empty.webp differ diff --git a/docs/images/basic-widgets.webp b/docs/images/basic-widgets.webp new file mode 100644 index 0000000..ed21962 Binary files /dev/null and b/docs/images/basic-widgets.webp differ diff --git a/docs/images/new-dashboard.webp b/docs/images/new-dashboard.webp index ab579ce..ef495a7 100644 Binary files a/docs/images/new-dashboard.webp and b/docs/images/new-dashboard.webp differ diff --git a/docs/widgets/images/weather-1.png b/docs/widgets/images/weather-1.png index bcea5d6..4303854 100644 Binary files a/docs/widgets/images/weather-1.png and b/docs/widgets/images/weather-1.png differ diff --git a/docs/widgets/images/weather-10.png b/docs/widgets/images/weather-10.png index 02e4446..d4d2029 100644 Binary files a/docs/widgets/images/weather-10.png and b/docs/widgets/images/weather-10.png differ diff --git a/docs/widgets/images/weather-11.png b/docs/widgets/images/weather-11.png index 1cd3bd8..fafcf23 100644 Binary files a/docs/widgets/images/weather-11.png and b/docs/widgets/images/weather-11.png differ diff --git a/docs/widgets/images/weather-12.png b/docs/widgets/images/weather-12.png index 28d7cc0..06251a4 100644 Binary files a/docs/widgets/images/weather-12.png and b/docs/widgets/images/weather-12.png differ diff --git a/docs/widgets/images/weather-2.png b/docs/widgets/images/weather-2.png index 3e1dfe5..92856a6 100644 Binary files a/docs/widgets/images/weather-2.png and b/docs/widgets/images/weather-2.png differ diff --git a/docs/widgets/images/weather-3.png b/docs/widgets/images/weather-3.png index 41335af..0be217a 100644 Binary files a/docs/widgets/images/weather-3.png and b/docs/widgets/images/weather-3.png differ diff --git a/docs/widgets/images/weather-4.png b/docs/widgets/images/weather-4.png index 489c3b8..66dc460 100644 Binary files a/docs/widgets/images/weather-4.png and b/docs/widgets/images/weather-4.png differ diff --git a/docs/widgets/images/weather-5.png b/docs/widgets/images/weather-5.png index 7c7592f..9ae5172 100644 Binary files a/docs/widgets/images/weather-5.png and b/docs/widgets/images/weather-5.png differ diff --git a/docs/widgets/images/weather-6.png b/docs/widgets/images/weather-6.png index fb0cfb8..dd7d1d5 100644 Binary files a/docs/widgets/images/weather-6.png and b/docs/widgets/images/weather-6.png differ diff --git a/docs/widgets/images/weather-7.png b/docs/widgets/images/weather-7.png index 7afb31c..0196f06 100644 Binary files a/docs/widgets/images/weather-7.png and b/docs/widgets/images/weather-7.png differ diff --git a/docs/widgets/images/weather-9.png b/docs/widgets/images/weather-9.png index 63ff15d..13bdea2 100644 Binary files a/docs/widgets/images/weather-9.png and b/docs/widgets/images/weather-9.png differ diff --git a/frontend-src/e2e/basic-editor.spec.ts b/frontend-src/e2e/basic-editor.spec.ts new file mode 100644 index 0000000..798b1bd --- /dev/null +++ b/frontend-src/e2e/basic-editor.spec.ts @@ -0,0 +1,348 @@ +import { expect, test, type Locator, type Page } from "@playwright/test"; +import { lastCall, openGallery } from "./helpers"; + +/** The basic editor: choose it when a dashboard is created, cut the screen, fill the regions. */ + +const createBasicDashboard = async (page: Page, name: string) => { + await page + .getByRole("button", { name: "New dashboard", exact: true }) + .click(); + const dialog = page.getByRole("dialog", { name: "New dashboard" }); + await dialog.getByRole("radio", { name: /Basic/ }).click(); + await dialog.getByRole("radio", { name: /Custom size/ }).click(); + await dialog.getByRole("textbox", { name: "Dashboard name" }).fill(name); + await dialog.getByRole("button", { name: "Create dashboard" }).click(); + await expect(page.locator("ods-region-layer")).toBeVisible(); +}; + +const setNumber = async (page: Page, label: string, value: string) => { + const field = page + .locator("ods-layout-panel") + .getByRole("spinbutton", { name: label }); + await field.fill(value); + await field.press("Enter"); +}; + +const cell = (page: Page, row: number, column: number) => + page.getByRole("button", { + name: `Cell in row ${row} and column ${column}`, + }); + +test.beforeEach(async ({ page }) => { + await openGallery(page); +}); + +test("a new dashboard is advanced unless the basic editor is chosen", async ({ + page, +}) => { + await page + .getByRole("button", { name: "New dashboard", exact: true }) + .click(); + const dialog = page.getByRole("dialog", { name: "New dashboard" }); + + await expect(dialog.getByRole("radio", { name: /Advanced/ })).toBeChecked(); + await expect(dialog.getByRole("radio", { name: /Basic/ })).not.toBeChecked(); +}); + +test("the basic editor starts with a grid of four cells and no structure tree", async ({ + page, +}) => { + await createBasicDashboard(page, "Basic board"); + + await expect(page.locator("ods-region-layer .cell")).toHaveCount(4); + await expect(page.locator("ods-structure")).toHaveCount(0); + await expect(page.locator(".arrange-bar")).toHaveCount(0); + await expect(page.locator("ods-zoom-bar").getByText("Fit")).toBeVisible(); +}); + +test("changing the columns and rows redraws the grid", async ({ page }) => { + await createBasicDashboard(page, "Basic board"); + + await setNumber(page, "Columns", "3"); + await setNumber(page, "Rows", "2"); + + await expect(page.locator("ods-region-layer .cell")).toHaveCount(6); +}); + +test("two clicks join the cells between them into one region, and a double click removes it", async ({ + page, +}) => { + await createBasicDashboard(page, "Basic board"); + + await cell(page, 1, 1).click(); + await cell(page, 1, 2).click(); + + await expect(page.locator("ods-region-layer .outline")).toHaveCount(3); + + await cell(page, 1, 1).dblclick(); + + await expect(page.locator("ods-region-layer .outline")).toHaveCount(4); +}); + +test("a joined region cannot be cut by another one", async ({ page }) => { + await createBasicDashboard(page, "Basic board"); + await setNumber(page, "Columns", "3"); + await cell(page, 1, 1).click(); + await cell(page, 1, 2).click(); + const regions = page.locator("ods-region-layer .outline"); + const before = await regions.count(); + + await cell(page, 1, 2).click(); + await cell(page, 1, 3).hover(); + await expect(page.getByRole("status")).toContainText("cut a region in two"); + await cell(page, 1, 3).click(); + + await expect(regions).toHaveCount(before); +}); + +test("a widget is put into a region and its properties show on the right", async ({ + page, +}) => { + await createBasicDashboard(page, "Basic board"); + await page.getByRole("button", { name: "Widgets", exact: true }).click(); + + await expect(page.locator("ods-library .catalog-item").first()).toBeVisible(); + await expect(page.locator("ods-library")).not.toContainText("Rectangle"); + await page.locator("ods-region-layer .slot").nth(1).click(); + await page + .locator("ods-library .catalog-item") + .filter({ hasText: "Sensor card" }) + .click(); + + await expect(page.locator("ods-region-layer .slot-label")).toHaveCount(1); + await expect(page.locator("ods-inspector")).toContainText("sensor-card_1"); + await expect(page.locator("ods-inspector")).not.toContainText("Layout"); +}); + +test("the dashboard is saved as basic, with its layout and the region of each widget", async ({ + page, +}) => { + await createBasicDashboard(page, "Basic board"); + await page.getByRole("button", { name: "Widgets", exact: true }).click(); + await page + .locator("ods-library .catalog-item") + .filter({ hasText: "Sensor card" }) + .click(); + await page.getByRole("button", { name: "Save", exact: true }).click(); + + const saved = await page.evaluate(() => window.__ODS_E2E__.dashboards()); + const dashboard = saved.find((item) => item.name === "Basic board"); + + expect(dashboard?.mode).toBe("basic"); + expect(dashboard?.layout?.regions).toHaveLength(4); + expect(dashboard?.items).toHaveLength(1); + expect(lastCall(page, "opendisplay_studio/update_dashboard")).toBeTruthy(); +}); + +test("the gallery marks each dashboard as basic or advanced", async ({ + page, +}) => { + await createBasicDashboard(page, "Basic board"); + await page.getByRole("button", { name: "Dashboards", exact: true }).click(); + + const card = (name: string) => + page.locator("ods-dashboard-card").filter({ hasText: name }); + await expect(card("Basic board")).toContainText("Basic"); + await expect(card("Kitchen display")).toContainText("Advanced"); +}); + +test("a basic dashboard reopens in the basic editor", async ({ page }) => { + await createBasicDashboard(page, "Basic board"); + await page.getByRole("button", { name: "Dashboards", exact: true }).click(); + + await page + .getByRole("button", { name: "Open dashboard Basic board" }) + .click(); + + await expect(page.locator("ods-region-layer")).toBeVisible(); + await expect(page.locator("ods-structure")).toHaveCount(0); +}); + +/** The letter on each region, with where the region lies, left to right then top to bottom. */ +const lettersOnRegions = (page: Page) => + page.locator("ods-region-layer .outline").evaluateAll((outlines) => + outlines + .map((outline) => ({ + letter: outline.querySelector(".letter")?.textContent ?? "", + left: Math.round(outline.getBoundingClientRect().left), + top: Math.round(outline.getBoundingClientRect().top), + })) + .sort((a, b) => a.left - b.left || a.top - b.top) + .map((region) => region.letter) + ); + +const join = async ( + page: Page, + from: [number, number], + to: [number, number] +) => { + await cell(page, ...from).click(); + await cell(page, ...to).click(); +}; + +test("regions are lettered in the order they were made", async ({ page }) => { + await createBasicDashboard(page, "Basic board"); + await setNumber(page, "Columns", "4"); + + await join(page, [1, 1], [1, 2]); + await join(page, [2, 1], [2, 2]); + await join(page, [1, 3], [2, 3]); + await join(page, [1, 4], [2, 4]); + + // Read from the left: the two wide regions, then the two tall ones. + expect(await lettersOnRegions(page)).toEqual(["A", "B", "C", "D"]); +}); + +test("a region made later does not take the letter of an earlier one", async ({ + page, +}) => { + await createBasicDashboard(page, "Basic board"); + await setNumber(page, "Columns", "4"); + + await join(page, [1, 3], [2, 3]); + await join(page, [1, 1], [1, 2]); + + // The tall region was made first, so it is A even though it lies to the right. + const letters = await page + .locator("ods-region-layer .outline") + .evaluateAll((outlines) => + outlines.map((outline) => ({ + letter: outline.querySelector(".letter")?.textContent, + size: outline.querySelector(".size")?.textContent?.trim(), + })) + ); + expect(letters.slice(0, 2)).toEqual([ + { letter: "A", size: "1×2 region" }, + { letter: "B", size: "2×1 region" }, + ]); +}); + +test("a double click removes a region and frees its cells", async ({ + page, +}) => { + await createBasicDashboard(page, "Basic board"); + await join(page, [1, 1], [1, 2]); + await expect(page.locator("ods-region-layer .outline")).toHaveCount(3); + + await cell(page, 1, 1).dblclick(); + + await expect(page.locator("ods-region-layer .outline")).toHaveCount(4); +}); + +test("a region as wide as the grid is removed by a double click too", async ({ + page, +}) => { + await createBasicDashboard(page, "Basic board"); + await setNumber(page, "Columns", "4"); + await setNumber(page, "Rows", "1"); + await join(page, [1, 1], [1, 4]); + await expect(page.locator("ods-region-layer .outline")).toHaveCount(1); + await expect(page.locator("ods-region-layer .size")).toHaveText("4×1 region"); + + await cell(page, 1, 3).dblclick(); + + await expect(page.locator("ods-region-layer .outline")).toHaveCount(4); +}); + +test("a region can be of any size, a tall one beside a square one and single cells", async ({ + page, +}) => { + await createBasicDashboard(page, "Basic board"); + await setNumber(page, "Columns", "4"); + await setNumber(page, "Rows", "4"); + + await join(page, [1, 1], [4, 1]); + await join(page, [1, 2], [2, 3]); + + const sizes = await page.locator("ods-region-layer .size").allTextContents(); + const joined = sizes.map((size) => size.trim()); + expect(joined.slice(0, 2)).toEqual(["1×4 region", "2×2 region"]); + expect(joined.slice(2).every((size) => size === "1×1 region")).toBe(true); + expect(joined).toHaveLength(2 + 4 + 4); +}); + +test("the layout panel has the grid and the padding, and the snap size is gone", async ({ + page, +}) => { + await createBasicDashboard(page, "Basic board"); + const panel = page.locator("ods-layout-panel"); + + for (const label of ["Columns", "Rows", "Gap", "Outer padding"]) { + await expect(panel.getByRole("spinbutton", { name: label })).toBeVisible(); + } + await expect(page.locator("ods-inspector")).not.toContainText("Snap size"); + await expect(page.locator("ods-inspector")).not.toContainText( + "Outer padding" + ); + + const padding = panel.getByRole("spinbutton", { name: "Outer padding" }); + await padding.fill("20"); + await padding.press("Enter"); + + await expect(page.locator(".workspace-meta")).toContainText("Padding 20px"); +}); + +const addWidgetToFirstRegion = async (page: Page) => { + await page.getByRole("button", { name: "Widgets", exact: true }).click(); + await page + .locator("ods-library .catalog-item") + .filter({ hasText: "Sensor card" }) + .click(); + await expect(page.locator("ods-region-layer .slot.selected")).toHaveCount(1); +}; + +const borderStyleOf = (locator: Locator) => + locator.evaluate((element) => getComputedStyle(element).borderTopStyle); + +test("a click beside the regions lets go of the selected one", async ({ + page, +}) => { + await createBasicDashboard(page, "Basic board"); + await addWidgetToFirstRegion(page); + await expect(page.locator("ods-inspector")).toContainText("sensor-card_1"); + + await page.locator(".canvas-stage").click({ position: { x: 20, y: 20 } }); + + await expect(page.locator("ods-region-layer .slot.selected")).toHaveCount(0); + await expect(page.locator("ods-inspector")).not.toContainText( + "sensor-card_1" + ); + await expect(page.locator("ods-inspector")).toContainText("Dashboard"); +}); + +test("an empty region selected is let go of the same way", async ({ page }) => { + await createBasicDashboard(page, "Basic board"); + await page.getByRole("button", { name: "Widgets", exact: true }).click(); + await page.locator("ods-region-layer .slot").nth(2).click(); + await expect(page.locator("ods-region-layer .slot.selected")).toHaveCount(1); + + await page.locator(".canvas-stage").click({ position: { x: 20, y: 20 } }); + + await expect(page.locator("ods-region-layer .slot.selected")).toHaveCount(0); +}); + +test("only empty regions are drawn with a dashed border", async ({ page }) => { + await createBasicDashboard(page, "Basic board"); + await addWidgetToFirstRegion(page); + await page.locator(".canvas-stage").click({ position: { x: 20, y: 20 } }); + await page.mouse.move(5, 5); + + const slots = page.locator("ods-region-layer .slot"); + await expect(slots.nth(0)).toHaveClass(/slot(?!.*empty)/); + expect(await borderStyleOf(slots.nth(0))).toBe("none"); + expect(await borderStyleOf(slots.nth(1))).toBe("dashed"); +}); + +test("a selected region with a widget is outlined, so its picture stays in view", async ({ + page, +}) => { + await createBasicDashboard(page, "Basic board"); + await addWidgetToFirstRegion(page); + + const selected = page.locator("ods-region-layer .slot.selected"); + expect(await borderStyleOf(selected)).toBe("solid"); + const tint = await selected.evaluate( + (element) => getComputedStyle(element).backgroundColor + ); + expect(tint).toBe("rgba(0, 0, 0, 0)"); +}); diff --git a/frontend-src/e2e/dashboard-library.spec.ts b/frontend-src/e2e/dashboard-library.spec.ts index 2951af1..6a52459 100644 --- a/frontend-src/e2e/dashboard-library.spec.ts +++ b/frontend-src/e2e/dashboard-library.spec.ts @@ -327,7 +327,7 @@ test("duplicates a dashboard as Draft and edits its display settings", async ({ .click(); await expect(dialog).toHaveCount(0); await expect(duplicate).toContainText("640 × 384"); - await expect(duplicate).toContainText("Spectra 6"); + await expect(duplicate.getByRole("img", { name: /Spectra 6/ })).toBeVisible(); const update = await page.evaluate(() => window.__ODS_E2E__ @@ -483,9 +483,7 @@ test.describe("starting a dashboard from a display", () => { "Hallway display · 800 × 480", "Kitchen e-paper · 296 × 128", ]); - const summary = dialog.getByRole("group", { - name: "Dashboard size and colors", - }); + const summary = dialog.getByRole("region", { name: "Display preview" }); await expect(summary).toContainText("800 × 480 px"); await expect(summary).toContainText("Black / white / red"); @@ -539,7 +537,7 @@ test.describe("starting a dashboard from a display", () => { await displays.selectOption("eink-spectra6-13-3"); await expect( - dialog.getByRole("group", { name: "Dashboard size and colors" }) + dialog.getByRole("region", { name: "Display preview" }) ).toContainText("1200 × 1600 px"); await dialog.getByRole("textbox", { name: "Dashboard name" }).fill("Big"); await dialog.getByRole("button", { name: "Create dashboard" }).click(); @@ -642,3 +640,151 @@ test.describe("the display settings of a dashboard", () => { ).toHaveCount(0); }); }); + +test.describe("the display preview of the new dashboard dialog", () => { + const openCustomDialog = async (page: Page) => { + await page + .getByRole("button", { name: "New dashboard", exact: true }) + .click(); + const dialog = page.getByRole("dialog", { name: "New dashboard" }); + await dialog.getByRole("radio", { name: /Custom size/ }).click(); + return dialog; + }; + + const display = (dialog: Locator) => + dialog.locator("ods-display-preview svg g").first(); + + test("turns the display a quarter at a time, which swaps its canvas", async ({ + page, + }) => { + const dialog = await openCustomDialog(page); + const preview = dialog.getByRole("region", { name: "Display preview" }); + await expect(preview).toContainText("800 × 480 px · 0°"); + + await dialog.getByRole("button", { name: "Turn 90° to the right" }).click(); + + await expect(preview).toContainText("480 × 800 px · 90°"); + await dialog.getByRole("button", { name: "Turn 90° to the left" }).click(); + await dialog.getByRole("button", { name: "Turn 90° to the left" }).click(); + await expect(preview).toContainText("480 × 800 px · 270°"); + }); + + test("the display leans back, swings round and settles at the new angle", async ({ + page, + }) => { + await page.emulateMedia({ reducedMotion: "no-preference" }); + const dialog = await openCustomDialog(page); + await expect(display(dialog)).toHaveAttribute("transform", /rotate\(0\)/); + + await dialog.getByRole("button", { name: "Turn 90° to the right" }).click(); + + await expect(display(dialog)).not.toHaveAttribute( + "transform", + /rotate\(90\)/ + ); + await expect(display(dialog)).toHaveAttribute("transform", /rotate\(90\)/); + }); + + test("a turned display is created turned, with the canvas it makes", async ({ + page, + }) => { + const dialog = await openCustomDialog(page); + await dialog.getByRole("textbox", { name: "Dashboard name" }).fill("Tall"); + await dialog.getByRole("button", { name: "Turn 90° to the right" }).click(); + + await dialog.getByRole("button", { name: "Create dashboard" }).click(); + + await expect(page.locator(".workspace-meta")).toContainText("480 × 800 px"); + const created = await page.evaluate( + () => + window.__ODS_E2E__ + .calls() + .findLast( + (call) => call.type === "opendisplay_studio/create_dashboard" + )?.dashboard as { display: { rotation: number } } | undefined + ); + expect(created?.display.rotation).toBe(90); + }); + + test("a basic dashboard has no padding or snapping to set here", async ({ + page, + }) => { + const dialog = await openCustomDialog(page); + await expect(dialog.getByText("Advanced display options")).toBeVisible(); + + await dialog.getByRole("radio", { name: /Basic/ }).click(); + + await expect(dialog.getByText("Advanced display options")).toHaveCount(0); + }); +}); + +test.describe("the Media Source picture in the display settings", () => { + const openSettings = async (page: Page, name: string) => { + const menu = await openDashboardMenu(page, name); + await menu + .getByRole("menuitem", { name: "Display Settings", exact: true }) + .click(); + return page.getByRole("dialog", { name: "Display settings", exact: true }); + }; + + test("is turned for the display until the setting is switched on", async ({ + page, + }) => { + const dialog = await openSettings(page, "Kitchen display"); + + await expect( + dialog.getByRole("checkbox", { name: "Media Source picture as designed" }) + ).not.toBeChecked(); + }); + + test("is kept as designed once the setting is saved", async ({ page }) => { + const dialog = await openSettings(page, "Kitchen display"); + + await dialog + .getByRole("checkbox", { name: "Media Source picture as designed" }) + .check(); + await dialog + .getByRole("button", { name: "Save changes", exact: true }) + .click(); + + await expect(dialog).toHaveCount(0); + const update = await lastCall(page, "opendisplay_studio/update_dashboard"); + expect(update).toMatchObject({ + dashboard: { display: { mediaAsDesigned: true } }, + }); + const reopened = await openSettings(page, "Kitchen display"); + await expect( + reopened.getByRole("checkbox", { + name: "Media Source picture as designed", + }) + ).toBeChecked(); + }); + + test("names the rotation to give opendisplay.upload_image for a turned display", async ({ + page, + }) => { + await page + .getByRole("button", { name: "New dashboard", exact: true }) + .click(); + const created = page.getByRole("dialog", { name: "New dashboard" }); + await created.getByRole("radio", { name: /Custom size/ }).click(); + await created.getByRole("textbox", { name: "Dashboard name" }).fill("Tall"); + await created + .getByRole("button", { name: "Turn 90° to the right" }) + .click(); + await created.getByRole("button", { name: "Create dashboard" }).click(); + await page.getByRole("button", { name: "Dashboards", exact: true }).click(); + + const dialog = await openSettings(page, "Tall"); + + await expect(dialog).toContainText("set rotation to 90"); + }); + + test("says there is nothing to give for a display that is not turned", async ({ + page, + }) => { + const dialog = await openSettings(page, "Kitchen display"); + + await expect(dialog).toContainText("the same either way"); + }); +}); diff --git a/frontend-src/e2e/dashboard-library.spec.ts-snapshots/new-dashboard-dialog-chromium-linux.png b/frontend-src/e2e/dashboard-library.spec.ts-snapshots/new-dashboard-dialog-chromium-linux.png index c145a25..f22917b 100644 Binary files a/frontend-src/e2e/dashboard-library.spec.ts-snapshots/new-dashboard-dialog-chromium-linux.png and b/frontend-src/e2e/dashboard-library.spec.ts-snapshots/new-dashboard-dialog-chromium-linux.png differ diff --git a/frontend-src/e2e/dashboard-library.spec.ts-snapshots/new-dashboard-dialog-chromium-win32.png b/frontend-src/e2e/dashboard-library.spec.ts-snapshots/new-dashboard-dialog-chromium-win32.png index 3e1d90d..4aa55cb 100644 Binary files a/frontend-src/e2e/dashboard-library.spec.ts-snapshots/new-dashboard-dialog-chromium-win32.png and b/frontend-src/e2e/dashboard-library.spec.ts-snapshots/new-dashboard-dialog-chromium-win32.png differ diff --git a/frontend-src/e2e/documentation-basic.spec.ts b/frontend-src/e2e/documentation-basic.spec.ts new file mode 100644 index 0000000..9b8105e --- /dev/null +++ b/frontend-src/e2e/documentation-basic.spec.ts @@ -0,0 +1,291 @@ +import { test, type Locator, type Page } from "@playwright/test"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { libraryItem, openGallery } from "./helpers"; + +/** + * Takes the pictures of the README's section on the basic editor, plain pictures of the + * panel as it is. It writes into `docs/images` and only runs when asked: + * `CAPTURE_DOCS=1 WIDGET_PICTURES= npx playwright test e2e/documentation-basic.spec.ts` + * where the file holds the widgets as the real renderer draws them (widget id → data URL). + */ +test.skip(!process.env.CAPTURE_DOCS, "writes the pictures of the README"); + +const IMAGES = path.join(import.meta.dirname, "..", "..", "docs", "images"); + +const widgetPictures = (): Record => { + const file = process.env.WIDGET_PICTURES; + if (!file) return {}; + return JSON.parse(readFileSync(file, "utf8")).pictures; +}; + +const picture = async (page: Page, name: string, area?: Locator) => { + const target = path.join(IMAGES, `${name}.png`); + if (!area) { + await page.screenshot({ path: target }); + return; + } + const box = await area.boundingBox(); + if (!box) throw new Error(`${name}: nothing to take a picture of`); + const margin = 12; + await page.screenshot({ + path: target, + clip: { + x: Math.max(0, box.x - margin), + y: Math.max(0, box.y - margin), + width: box.width + 2 * margin, + height: box.height + 2 * margin, + }, + }); +}; + +const cell = (page: Page, row: number, column: number) => + page.getByRole("button", { + name: `Cell in row ${row} and column ${column}`, + }); + +const setNumber = async (page: Page, label: string, value: string) => { + const field = page + .locator("ods-layout-panel") + .getByRole("spinbutton", { name: label }); + await field.fill(value); + await field.press("Enter"); +}; + +const putWidgetInRegion = async ( + page: Page, + region: number, + widget: string +) => { + await page.locator("ods-region-layer .slot").nth(region).click(); + await libraryItem(page, widget).click(); +}; + +interface SampleDashboard { + name: string; + mode: "basic" | "advanced"; + status: "draft" | "ready"; + palette: string; + background: string; + size: [number, number]; + rotation?: number; +} + +/** Every kind of color scheme there is, so the list shows each way a card can look. */ +const SAMPLES: SampleDashboard[] = [ + { + name: "Kitchen display", + mode: "advanced", + status: "ready", + palette: "bwr", + background: "white", + size: [800, 480], + }, + { + name: "Living room", + mode: "basic", + status: "draft", + palette: "spectra6", + background: "white", + size: [800, 480], + }, + { + name: "Night clock", + mode: "advanced", + status: "ready", + palette: "bwr", + background: "black", + size: [800, 480], + }, + { + name: "Photo frame", + mode: "basic", + status: "draft", + palette: "grayscale16", + background: "white", + size: [800, 480], + }, + { + name: "Door sign", + mode: "advanced", + status: "ready", + palette: "bw", + background: "white", + size: [296, 128], + }, + { + name: "Hallway panel", + mode: "advanced", + status: "ready", + palette: "bwr", + background: "white", + size: [480, 800], + rotation: 90, + }, + { + name: "Desk plate", + mode: "basic", + status: "draft", + palette: "bw", + background: "white", + size: [800, 480], + rotation: 180, + }, +]; + +test.beforeEach(async ({ page }) => { + const pictures = widgetPictures(); + await page.addInitScript((value) => { + window.__ODS_PROMO__ = { widgetPictures: value }; + }, pictures); + await page.setViewportSize({ width: 1440, height: 900 }); + await openGallery(page); +}); + +test("the list of dashboards", async ({ page }) => { + await page.addInitScript((samples) => { + const dashboards = samples.map((sample, index) => ({ + id: `sample-${index}`, + schemaVersion: 1, + name: sample.name, + mode: sample.mode, + status: sample.status, + language: "en", + display: { + profileId: "custom", + width: sample.size[0], + height: sample.size[1], + palette: sample.palette, + background: sample.background, + padding: 0, + snapSize: 5, + rotation: sample.rotation ?? 0, + deviceId: null, + }, + items: [], + createdAt: "2026-09-20T10:00:00+00:00", + updatedAt: `2026-09-${24 - index}T10:00:00+00:00`, + })); + window.__ODS_PROMO__ = { dashboards: dashboards as never }; + }, SAMPLES); + await page.reload(); + await page.getByText("Night clock").waitFor(); + + await picture(page, "basic-gallery"); +}); + +test("the basic editor, step by step", async ({ page }) => { + await page + .getByRole("button", { name: "New dashboard", exact: true }) + .click(); + const dialog = page.getByRole("dialog", { name: "New dashboard" }); + await dialog.getByRole("radio", { name: /Basic/ }).click(); + await dialog.getByRole("radio", { name: /Custom size/ }).click(); + await dialog + .getByRole("textbox", { name: "Dashboard name" }) + .fill("Living room"); + await picture(page, "basic-new-dashboard", dialog); + await dialog.getByRole("button", { name: "Create dashboard" }).click(); + await page.locator("ods-region-layer").waitFor(); + + await setNumber(page, "Columns", "3"); + await setNumber(page, "Rows", "2"); + await picture(page, "basic-grid"); + + await cell(page, 1, 1).click(); + await cell(page, 1, 2).hover(); + await picture(page, "basic-join"); + await cell(page, 1, 2).click(); + await cell(page, 2, 2).click(); + await cell(page, 2, 3).click(); + await cell(page, 1, 1).click(); + await page.keyboard.press("Escape"); + // Leave the keyboard focus ring and the pointer out of the picture. + await page.locator(".canvas-stage").click({ position: { x: 20, y: 20 } }); + await page.mouse.move(5, 5); + await picture(page, "basic-regions"); + + await page.getByRole("button", { name: "Widgets", exact: true }).click(); + await picture(page, "basic-widgets-empty"); + await putWidgetInRegion(page, 0, "Weather"); + await putWidgetInRegion(page, 1, "Calendar"); + await putWidgetInRegion(page, 2, "Thermometer"); + await putWidgetInRegion(page, 3, "Gauge"); + await page.locator("ods-region-layer .slot").nth(0).click(); + await picture(page, "basic-widgets"); + // A click beside the regions lets go of the selection: what is left is the picture itself. + await page.locator(".canvas-stage").click({ position: { x: 20, y: 20 } }); + await page.mouse.move(5, 5); + await picture(page, "basic-result"); +}); + +/** The regions of a fine grid, as pairs of corner cells (row, column) joined in this order. */ +const MANY_REGIONS: Array<[[number, number], [number, number]]> = [ + [ + [1, 1], + [2, 2], + ], + [ + [1, 3], + [1, 6], + ], + [ + [2, 3], + [2, 4], + ], + [ + [2, 5], + [4, 5], + ], + [ + [2, 6], + [3, 6], + ], + [ + [3, 1], + [3, 4], + ], + [ + [4, 1], + [4, 2], + ], +]; + +test("a grid with as many regions as you like", async ({ page }) => { + await page + .getByRole("button", { name: "New dashboard", exact: true }) + .click(); + const dialog = page.getByRole("dialog", { name: "New dashboard" }); + await dialog.getByRole("radio", { name: /Basic/ }).click(); + await dialog.getByRole("radio", { name: /Custom size/ }).click(); + await dialog + .getByRole("textbox", { name: "Dashboard name" }) + .fill("Control room"); + await dialog.getByRole("button", { name: "Create dashboard" }).click(); + await page.locator("ods-region-layer").waitFor(); + + await setNumber(page, "Columns", "6"); + await setNumber(page, "Rows", "4"); + for (const [from, to] of MANY_REGIONS) { + await cell(page, ...from).click(); + await cell(page, ...to).click(); + } + await page.getByRole("button", { name: "Widgets", exact: true }).click(); + await page.locator(".canvas-stage").click({ position: { x: 20, y: 20 } }); + await page.mouse.move(5, 5); + await picture(page, "basic-many-regions"); +}); + +test("the new dashboard dialog, with the display turned", async ({ page }) => { + await page + .getByRole("button", { name: "New dashboard", exact: true }) + .click(); + const dialog = page.getByRole("dialog", { name: "New dashboard" }); + await dialog + .getByRole("textbox", { name: "Dashboard name" }) + .fill("Hallway panel"); + await dialog.getByRole("button", { name: "Turn 90° to the right" }).click(); + // Wait for the turn to settle: it leans back, swings round and comes to rest. + await page.waitForTimeout(1200); + await picture(page, "new-dashboard", dialog); +}); diff --git a/frontend-src/e2e/elements.spec.ts b/frontend-src/e2e/elements.spec.ts index e70a11d..3da602c 100644 --- a/frontend-src/e2e/elements.spec.ts +++ b/frontend-src/e2e/elements.spec.ts @@ -843,10 +843,10 @@ test.describe("rotation and sending to a device", () => { .click(); const dialog = page.getByRole("dialog", { name: "New dashboard" }); - await dialog.getByRole("combobox", { name: "Rotation" }).selectOption("90"); + await dialog.getByRole("button", { name: "Turn 90° to the right" }).click(); await expect( - dialog.getByRole("group", { name: "Dashboard size and colors" }) + dialog.getByRole("region", { name: "Display preview" }) ).toContainText("480 × 800 px"); await dialog.getByRole("textbox", { name: "Dashboard name" }).fill("Tall"); await dialog.getByRole("button", { name: "Create dashboard" }).click(); diff --git a/frontend-src/package-lock.json b/frontend-src/package-lock.json index 6060e75..831a86f 100644 --- a/frontend-src/package-lock.json +++ b/frontend-src/package-lock.json @@ -1,12 +1,12 @@ { "name": "opendisplay-studio-ha-panel", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opendisplay-studio-ha-panel", - "version": "0.2.0", + "version": "0.3.0", "dependencies": { "@mdi/js": "^7.4.47", "lit": "^3.3.3" diff --git a/frontend-src/package.json b/frontend-src/package.json index bc6f5e2..62b7c63 100644 --- a/frontend-src/package.json +++ b/frontend-src/package.json @@ -1,7 +1,7 @@ { "name": "opendisplay-studio-ha-panel", "private": true, - "version": "0.2.0", + "version": "0.3.0", "type": "module", "scripts": { "dev": "vite build --watch", diff --git a/frontend-src/src/basic-ops.test.ts b/frontend-src/src/basic-ops.test.ts new file mode 100644 index 0000000..8989297 --- /dev/null +++ b/frontend-src/src/basic-ops.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "vitest"; +import { + mergeRegionsIn, + occupiedRegionIds, + placeWidget, + regionForNewWidget, + resizeGridIn, + splitRegionIn, + widgetInRegion, + withMode, +} from "./basic-ops"; +import { rectangleBetween } from "./grid-layout"; +import { dashboardWith, widgetDefinition } from "./test-support"; +import type { Dashboard, GridLayout } from "./types"; + +const FRAME = { x: 0, y: 0, width: 100, height: 50 }; + +const basicDashboard = (): Dashboard => withMode(dashboardWith(), "basic", 8); + +const layoutOf = (dashboard: Dashboard): GridLayout => { + if (!dashboard.layout) throw new Error("The dashboard has no layout"); + return dashboard.layout; +}; + +const fill = (dashboard: Dashboard, regionId: string): void => { + const region = layoutOf(dashboard).regions.find((r) => r.id === regionId); + if (!region) throw new Error("No such region"); + placeWidget(dashboard, widgetDefinition("sensor-card"), region, FRAME); +}; + +describe("withMode", () => { + it("starts a basic dashboard with a grid of single-cell regions", () => { + const dashboard = basicDashboard(); + + expect(dashboard.mode).toBe("basic"); + expect(layoutOf(dashboard).regions).toHaveLength(4); + expect(layoutOf(dashboard).gap).toBe(8); + }); + + it("leaves an advanced dashboard without a layout", () => { + const dashboard = withMode(basicDashboard(), "advanced", 8); + + expect(dashboard.mode).toBe("advanced"); + expect(dashboard.layout).toBeUndefined(); + }); +}); + +describe("placeWidget", () => { + it("fills a region with a widget that takes the region's frame", () => { + const dashboard = basicDashboard(); + const [first] = layoutOf(dashboard).regions; + + const widget = placeWidget( + dashboard, + widgetDefinition("sensor-card"), + first, + FRAME + ); + + expect(widget.region).toBe(first.id); + expect(widget.frame).toEqual(FRAME); + expect(widgetInRegion(dashboard, first.id)).toBe(widget); + }); +}); + +describe("regionForNewWidget", () => { + it("prefers the chosen region while it is free", () => { + const dashboard = basicDashboard(); + const layout = layoutOf(dashboard); + + const region = regionForNewWidget(dashboard, layout, layout.regions[2].id); + + expect(region?.id).toBe(layout.regions[2].id); + }); + + it("falls back to the first free region when the chosen one is taken", () => { + const dashboard = basicDashboard(); + const layout = layoutOf(dashboard); + fill(dashboard, layout.regions[0].id); + + const region = regionForNewWidget(dashboard, layout, layout.regions[0].id); + + expect(region?.id).toBe(layout.regions[1].id); + }); + + it("finds nothing when every region holds a widget", () => { + const dashboard = basicDashboard(); + const layout = layoutOf(dashboard); + layout.regions.forEach((region) => fill(dashboard, region.id)); + + expect(regionForNewWidget(dashboard, layout, "")).toBeUndefined(); + }); +}); + +describe("mergeRegionsIn", () => { + it("releases the widget of a region that is joined into another one", () => { + const dashboard = basicDashboard(); + const [first, second] = layoutOf(dashboard).regions; + fill(dashboard, second.id); + + mergeRegionsIn( + dashboard, + rectangleBetween({ row: 0, column: 0 }, { row: 0, column: 1 }) + ); + + expect(occupiedRegionIds(dashboard)).toEqual(new Set([second.id])); + expect(layoutOf(dashboard).regions.some((r) => r.id === first.id)).toBe( + false + ); + }); +}); + +describe("splitRegionIn", () => { + it("keeps the widget in the first cell of the region it was cut from", () => { + const dashboard = basicDashboard(); + mergeRegionsIn( + dashboard, + rectangleBetween({ row: 0, column: 0 }, { row: 1, column: 1 }) + ); + const [whole] = layoutOf(dashboard).regions; + fill(dashboard, whole.id); + + splitRegionIn(dashboard, whole.id); + + expect(layoutOf(dashboard).regions).toHaveLength(4); + expect(widgetInRegion(dashboard, whole.id)).toBeDefined(); + }); +}); + +describe("resizeGridIn", () => { + it("removes the widgets of the regions the smaller grid no longer has", () => { + const dashboard = basicDashboard(); + layoutOf(dashboard).regions.forEach((region) => fill(dashboard, region.id)); + + resizeGridIn(dashboard, { columns: 1, rows: 1 }); + + expect(dashboard.items).toHaveLength(1); + expect(layoutOf(dashboard).regions).toHaveLength(1); + }); +}); diff --git a/frontend-src/src/basic-ops.ts b/frontend-src/src/basic-ops.ts new file mode 100644 index 0000000..ddf7547 --- /dev/null +++ b/frontend-src/src/basic-ops.ts @@ -0,0 +1,117 @@ +import { createWidgetItem } from "./dashboard-ops"; +import { + evenLayout, + mergeRegions, + resizeGrid, + splitRegion, + type CellRectangle, + type LayoutChange, +} from "./grid-layout"; +import type { + Dashboard, + GridLayout, + GridRegion, + ItemFrame, + StudioItem, + WidgetDefinition, + WidgetItem, +} from "./types"; + +/** What a new basic dashboard starts with: a grid of four cells. */ +export const STARTING_COLUMNS = 2; +export const STARTING_ROWS = 2; + +/** A dashboard of the basic editor, started with its grid, or an advanced one without. */ +export const withMode = ( + dashboard: Dashboard, + mode: Dashboard["mode"], + gap: number +): Dashboard => { + const next = structuredClone(dashboard); + next.mode = mode; + if (mode === "basic") { + next.layout = evenLayout(STARTING_COLUMNS, STARTING_ROWS, gap); + } else { + delete next.layout; + } + return next; +}; + +/** Every widget of a basic dashboard holds one region; the rest of its items are none. */ +export const widgetsOf = (dashboard: Dashboard): WidgetItem[] => + dashboard.items.filter((item): item is WidgetItem => item.kind === "widget"); + +export const widgetInRegion = ( + dashboard: Dashboard, + regionId: string +): WidgetItem | undefined => + widgetsOf(dashboard).find((widget) => widget.region === regionId); + +export const occupiedRegionIds = (dashboard: Dashboard): Set => + new Set(widgetsOf(dashboard).flatMap((widget) => widget.region ?? [])); + +/** The region a new widget goes to: the chosen one if it is free, else the first free one. */ +export const regionForNewWidget = ( + dashboard: Dashboard, + layout: GridLayout, + chosenRegionId: string +): GridRegion | undefined => { + const occupied = occupiedRegionIds(dashboard); + const free = layout.regions.filter((region) => !occupied.has(region.id)); + return free.find((region) => region.id === chosenRegionId) ?? free[0]; +}; + +/** Puts a new widget of the given kind into a region, as the last item of the dashboard. */ +export const placeWidget = ( + dashboard: Dashboard, + definition: WidgetDefinition, + region: GridRegion, + frame: ItemFrame +): WidgetItem => { + const widget = createWidgetItem(definition, 0, 0, dashboard); + widget.region = region.id; + widget.frame = { ...frame }; + dashboard.items.push(widget); + return widget; +}; + +const withoutWidgetsIn = ( + items: StudioItem[], + regionIds: string[] +): StudioItem[] => + items.filter( + (item) => item.kind !== "widget" || !regionIds.includes(item.region ?? "") + ); + +/** Replaces the layout and takes away the widgets of the regions the change removed. */ +const applyChange = (dashboard: Dashboard, change: LayoutChange): void => { + dashboard.layout = change.layout; + dashboard.items = withoutWidgetsIn(dashboard.items, change.removedRegionIds); +}; + +export const mergeRegionsIn = ( + dashboard: Dashboard, + rectangle: CellRectangle +): void => { + if (!dashboard.layout) return; + const occupied = occupiedRegionIds(dashboard); + applyChange(dashboard, mergeRegions(dashboard.layout, rectangle, occupied)); +}; + +export const splitRegionIn = (dashboard: Dashboard, regionId: string): void => { + if (!dashboard.layout) return; + dashboard.layout = splitRegion(dashboard.layout, regionId); +}; + +export const resizeGridIn = ( + dashboard: Dashboard, + size: { columns: number; rows: number } +): void => { + if (!dashboard.layout) return; + applyChange(dashboard, resizeGrid(dashboard.layout, size.columns, size.rows)); +}; + +export const setGapIn = (dashboard: Dashboard, gap: number): void => { + if (!dashboard.layout) return; + dashboard.layout = { ...dashboard.layout, gap }; +}; diff --git a/frontend-src/src/commands.ts b/frontend-src/src/commands.ts index 7cba595..cbea485 100644 --- a/frontend-src/src/commands.ts +++ b/frontend-src/src/commands.ts @@ -70,6 +70,8 @@ export interface CommandContext { canPaste?: boolean; /** Whether the dashboard has unsaved changes. */ dirty?: boolean; + /** Whether the dashboard is edited by regions, which leaves out most editing commands. */ + basic?: boolean; } /** The effects a command can have; the shell provides them. */ @@ -240,7 +242,7 @@ const distributeCommands = (): Command[] => }) ); -export const COMMANDS: Command[] = [ +const EDITOR_COMMANDS: Command[] = [ { id: "undo", group: "history", @@ -513,6 +515,33 @@ export const COMMANDS: Command[] = [ }, ]; +/** + * What a basic dashboard still allows. The rest moves, copies or regroups elements, and + * in a basic dashboard a widget goes where its region is and nowhere else. + */ +const BASIC_COMMAND_IDS: ReadonlySet = new Set([ + "undo", + "redo", + "delete-item", + "deselect", + "save", + "toggle-code", + "zoom-in", + "zoom-out", + "zoom-reset", + "show-shortcuts", +]); + +const forDashboardMode = (command: Command): Command => { + if (BASIC_COMMAND_IDS.has(command.id)) return command; + return { + ...command, + isEnabled: (context) => !context.basic && command.isEnabled(context), + }; +}; + +export const COMMANDS: Command[] = EDITOR_COMMANDS.map(forDashboardMode); + /** Whether a string is the id of a registered command. */ export const isCommandId = (id: string): id is CommandId => COMMANDS.some((command) => command.id === id); diff --git a/frontend-src/src/dashboards.test.ts b/frontend-src/src/dashboards.test.ts index 4240520..6814e47 100644 --- a/frontend-src/src/dashboards.test.ts +++ b/frontend-src/src/dashboards.test.ts @@ -4,6 +4,7 @@ import { dashboardAccent, dashboardDate, dashboardFormData, + dashboardFormHelper, dashboardFormLabel, dashboardFormSchema, dashboardFromDevice, @@ -36,6 +37,52 @@ describe("freshDashboard", () => { }); }); +describe("the Media Source picture of a dashboard", () => { + it("is turned for the display unless the dashboard says otherwise", () => { + expect(freshDashboard("en").display.mediaAsDesigned).toBe(false); + }); + + it("is a setting of the form that the dashboard is made from", () => { + const dashboard = dashboardWith([], { mediaAsDesigned: true }); + + expect(dashboardFormData(dashboard).mediaAsDesigned).toBe(true); + const asked = dashboardFromForm(dashboardWith(), { mediaAsDesigned: true }); + expect(asked.display.mediaAsDesigned).toBe(true); + const undone = dashboardFromForm(dashboard, { mediaAsDesigned: false }); + expect(undone.display.mediaAsDesigned).toBe(false); + }); + + it("is left as it was when the form changes something else", () => { + const dashboard = dashboardWith([], { mediaAsDesigned: true }); + + const next = dashboardFromForm(dashboard, { name: "Other" }); + + expect(next.display.mediaAsDesigned).toBe(true); + }); + + it("is offered in the display settings", () => { + const names = dashboardFormSchema().map((entry) => entry.name); + + expect(names).toContain("mediaAsDesigned"); + }); + + it("says which rotation to give opendisplay.upload_image", () => { + const helper = dashboardFormHelper(90); + const field = { name: "mediaAsDesigned", label: "", selector: {} }; + + expect(helper(field)).toContain("rotation to 90"); + expect(helper({ ...field, name: "width" })).toBe(""); + }); + + it("needs no rotation to be told for a dashboard that is not turned", () => { + const helper = dashboardFormHelper(0); + + const text = helper({ name: "mediaAsDesigned", label: "", selector: {} }); + + expect(text).not.toContain("set rotation"); + }); +}); + describe("dashboard form", () => { it("round-trips the editable display fields", () => { const dashboard = dashboardWith([], { @@ -257,11 +304,12 @@ describe("starting from a display", () => { expect(names({ size: false, palettes: [], backgrounds: [] })).toEqual([ "name", "rotation", + "mediaAsDesigned", "advanced", ]); expect( names({ size: false, palettes: ["bw", "bwr"], backgrounds: [] }) - ).toEqual(["name", "palette", "rotation", "advanced"]); + ).toEqual(["name", "palette", "rotation", "mediaAsDesigned", "advanced"]); expect( names({ size: false, palettes: ["bw"], backgrounds: [] }) ).not.toContain("palette"); @@ -270,6 +318,7 @@ describe("starting from a display", () => { "dimensions", "palette", "rotation", + "mediaAsDesigned", "advanced", ]); }); diff --git a/frontend-src/src/dashboards.ts b/frontend-src/src/dashboards.ts index 88e4927..4954014 100644 --- a/frontend-src/src/dashboards.ts +++ b/frontend-src/src/dashboards.ts @@ -20,6 +20,15 @@ export type DashboardSource = "device" | "preset" | "custom"; export const ROTATIONS: Rotation[] = [0, 90, 180, 270]; +/** The rotation a quarter turn to the right (1) or to the left (-1) leads to. */ +export const turnedRotation = ( + rotation: Rotation, + direction: 1 | -1 +): Rotation => { + const index = ROTATIONS.indexOf(rotation); + return ROTATIONS[(index + direction + ROTATIONS.length) % ROTATIONS.length]; +}; + export const isRotation = (value: number): value is Rotation => ROTATIONS.some((rotation) => rotation === value); @@ -63,6 +72,7 @@ export interface DashboardFormData { rotation: string; padding: number; snapSize: number; + mediaAsDesigned: boolean; } export const freshDashboard = ( @@ -74,6 +84,7 @@ export const freshDashboard = ( id: "", schemaVersion: 1, name: "", + mode: "advanced", status: "draft", language: language || "en", display: { @@ -86,6 +97,7 @@ export const freshDashboard = ( snapSize: 5, rotation: 0, deviceId: null, + mediaAsDesigned: false, }, items: [], createdAt: "", @@ -102,6 +114,7 @@ export const dashboardFormData = (dashboard: Dashboard): DashboardFormData => ({ rotation: String(dashboard.display.rotation), padding: dashboard.display.padding, snapSize: dashboard.display.snapSize, + mediaAsDesigned: dashboard.display.mediaAsDesigned, }); interface DisplaySettings { @@ -182,6 +195,7 @@ export const dashboardFromForm = ( next.display.palette = value.palette in PALETTE_LABELS ? value.palette : "bw"; next.display.padding = Math.round(Number(value.padding) || 0); next.display.snapSize = Math.round(Number(value.snapSize) || 0); + next.display.mediaAsDesigned = value.mediaAsDesigned === true; const colors = PALETTE_COLORS[next.display.palette]; next.display.background = colors.includes(value.background) ? value.background @@ -280,7 +294,7 @@ const pixelField = (name: string, label: string): StudioFormSchema => ({ }, }); -const sizeFields = (): StudioFormSchema[] => [ +export const sizeFields = (): StudioFormSchema[] => [ { name: "dimensions", type: "grid", @@ -292,7 +306,7 @@ const sizeFields = (): StudioFormSchema[] => [ }, ]; -const paletteFields = (palettes: PaletteId[]): StudioFormSchema[] => [ +export const paletteFields = (palettes: PaletteId[]): StudioFormSchema[] => [ { name: "palette", label: strings.fields.palette, @@ -343,7 +357,25 @@ const rotationFields = (): StudioFormSchema[] => [ }, ]; -const advancedFields = (): StudioFormSchema[] => [ +export const MEDIA_AS_DESIGNED = "mediaAsDesigned"; + +const mediaFields = (): StudioFormSchema[] => [ + { + name: MEDIA_AS_DESIGNED, + label: strings.fields.mediaAsDesigned, + selector: { boolean: {} }, + }, +]; + +/** What a form tells about a field below it; only the Media Source switch has anything to say. */ +export const dashboardFormHelper = + (rotation: Rotation) => + (entry: StudioFormSchema): string => + "name" in entry && entry.name === MEDIA_AS_DESIGNED + ? strings.fields.mediaAsDesignedHelp(rotation) + : ""; + +export const advancedFields = (): StudioFormSchema[] => [ { name: "advanced", type: "expandable", @@ -369,22 +401,27 @@ const advancedFields = (): StudioFormSchema[] => [ }, ]; -/** The `ha-form` schema shared by the create and display-settings dialogs. */ -export const dashboardFormSchema = ( - display: DisplayFields = ALL_DISPLAY_FIELDS -): StudioFormSchema[] => [ +export const nameFields = (): StudioFormSchema[] => [ { name: "name", label: strings.fields.name, required: true, selector: { text: {} }, }, +]; + +/** The `ha-form` schema shared by the create and display-settings dialogs. */ +export const dashboardFormSchema = ( + display: DisplayFields = ALL_DISPLAY_FIELDS +): StudioFormSchema[] => [ + ...nameFields(), ...(display.size ? sizeFields() : []), ...(display.palettes.length > 1 ? paletteFields(display.palettes) : []), ...(display.backgrounds.length > 0 ? backgroundFields(display.backgrounds) : []), ...rotationFields(), + ...mediaFields(), ...advancedFields(), ]; diff --git a/frontend-src/src/dev-layout.ts b/frontend-src/src/dev-layout.ts new file mode 100644 index 0000000..d29e212 --- /dev/null +++ b/frontend-src/src/dev-layout.ts @@ -0,0 +1,80 @@ +import type { Dashboard, ItemFrame, LayoutFrames } from "./types"; + +/** + * The harness's stand-in for the backend's grid: the same split as the SDK's `grid()`, so + * what the panel shows in the harness has the numbers the real backend gives. + */ + +/** Python rounds halves to the even number; so does the backend's split. */ +const roundHalfEven = (value: number): number => { + const floor = Math.floor(value); + const difference = value - floor; + if (difference !== 0.5) return Math.round(value); + return floor % 2 === 0 ? floor : floor + 1; +}; + +const splitLine = ( + total: number, + count: number, + gap: number +): Array<{ offset: number; length: number }> => { + const available = Math.max(count, total - gap * (count - 1)); + const spans: Array<{ offset: number; length: number }> = []; + let start = 0; + for (let index = 0; index < count; index += 1) { + const end = roundHalfEven((available * (index + 1)) / count); + spans.push({ + offset: start + gap * index, + length: Math.max(1, end - start), + }); + start = end; + } + return spans; +}; + +export const mockLayoutFrames = (dashboard: Dashboard): LayoutFrames => { + const layout = dashboard.layout; + if (!layout) return { cells: [], regions: [] }; + const { width, height, padding } = dashboard.display; + const columns = splitLine(width - 2 * padding, layout.columns, layout.gap); + const rows = splitLine(height - 2 * padding, layout.rows, layout.gap); + const cells: ItemFrame[] = rows.flatMap((row) => + columns.map((column) => ({ + x: padding + column.offset, + y: padding + row.offset, + width: column.length, + height: row.length, + })) + ); + const regions = layout.regions.map((region) => { + const first = cells[region.row * layout.columns + region.column]; + const last = + cells[ + (region.row + region.rowSpan - 1) * layout.columns + + region.column + + region.columnSpan - + 1 + ]; + return { + id: region.id, + x: first.x, + y: first.y, + width: last.x + last.width - first.x, + height: last.y + last.height - first.y, + }; + }); + return { cells, regions }; +}; + +/** The dashboard with every widget in the frame of its region, as the backend does. */ +export const withRegionFrames = (dashboard: Dashboard): Dashboard => { + if (dashboard.mode !== "basic") return dashboard; + const { regions } = mockLayoutFrames(dashboard); + const placed = structuredClone(dashboard); + for (const item of placed.items) { + if (item.kind !== "widget") continue; + const frame = regions.find((region) => region.id === item.region); + if (frame) item.frame = { ...frame }; + } + return placed; +}; diff --git a/frontend-src/src/dev.ts b/frontend-src/src/dev.ts index 31ca0a5..b6d9bc9 100644 --- a/frontend-src/src/dev.ts +++ b/frontend-src/src/dev.ts @@ -10,6 +10,7 @@ import robotoThin from "@fontsource/roboto/files/roboto-latin-100-normal.woff2?u import robotoBlack from "@fontsource/roboto/files/roboto-latin-900-normal.woff2?url"; import robotoMonoBold from "@fontsource/roboto-mono/files/roboto-mono-latin-700-normal.woff2?url"; import robotoMono from "@fontsource/roboto-mono/files/roboto-mono-latin-400-normal.woff2?url"; +import { mockLayoutFrames, withRegionFrames } from "./dev-layout"; import { createId } from "./ids"; import { PALETTE_COLORS, PALETTE_IDS } from "./palettes"; import { loadPrimitiveDefinitions } from "./primitive-definitions"; @@ -30,6 +31,8 @@ import type { Primitive, PrimitiveItem, StudioItem, + WidgetItem, + GridLayout, } from "./types"; const primitiveDefinitions = loadPrimitiveDefinitions(); @@ -143,7 +146,7 @@ if (!customElements.get("ha-icon")) { "mdi:plus-circle": "⊕", "mdi:dots-horizontal": "⋯", "mdi:pencil-outline": "✎", - "mdi:monitor-cog": "⚙", + "mdi:cog-outline": "⚙", "mdi:delete-outline": "×", "mdi:monitor-edit": "▣", "mdi:monitor": "▣", @@ -286,12 +289,19 @@ if (!customElements.get("ha-form")) { private formData: Record = {}; private formSchema: DemoFormSchema[] = []; private labelFor?: (entry: DemoFormSchema) => string; + private helperFor?: (entry: DemoFormSchema) => string; private openSections = new Set(); set hass(_value: unknown) {} set computeLabel(value: ((entry: DemoFormSchema) => string) | undefined) { this.labelFor = value; this.draw(); } + set computeHelper( + value: ((entry: DemoFormSchema) => string) | undefined + ) { + this.helperFor = value; + this.draw(); + } set data(value: Record) { this.formData = value; this.draw(); @@ -349,9 +359,19 @@ if (!customElements.get("ha-form")) { target.append(details); } else if (entry.name && entry.selector) { target.append(this.renderField(entry)); + const helper = this.helperFor?.(entry); + if (helper) target.append(this.renderHelper(helper)); } } } + private renderHelper(text: string): HTMLElement { + const helper = document.createElement("small"); + helper.className = "form-helper"; + helper.style.cssText = + "display:block;margin:-6px 0 12px;color:#68727a;font:11px Roboto,sans-serif"; + helper.textContent = text; + return helper; + } private renderField(field: DemoFormSchema): HTMLLabelElement { const name = field.name!; const selector = field.selector!; @@ -570,6 +590,7 @@ const demoDashboard: Dashboard = { id: "demo", schemaVersion: 1, name: "Kitchen display", + mode: "advanced", status: "draft", language: "en", display: { @@ -582,6 +603,7 @@ const demoDashboard: Dashboard = { snapSize: 5, rotation: 0, deviceId: "hallway", + mediaAsDesigned: false, }, items: [ { @@ -608,6 +630,7 @@ const hallwayDashboard: Dashboard = { id: "hallway", schemaVersion: 1, name: "Hallway overview", + mode: "advanced", status: "ready", language: "en", display: { @@ -620,6 +643,7 @@ const hallwayDashboard: Dashboard = { snapSize: 5, rotation: 0, deviceId: null, + mediaAsDesigned: false, }, items: [ { @@ -739,6 +763,7 @@ const officeDashboard: Dashboard = { id: "office", schemaVersion: 1, name: "Office status", + mode: "advanced", status: "draft", language: "en", display: { @@ -751,6 +776,7 @@ const officeDashboard: Dashboard = { snapSize: 5, rotation: 0, deviceId: null, + mediaAsDesigned: false, }, items: [], createdAt: "2026-09-18T10:00:00+00:00", @@ -771,13 +797,38 @@ interface PromoHooks { bounds?: Record; /** Where the renderer turned each rotated primitive about. */ pivots?: Record; + /** A widget as the renderer drew it, by widget type, to show in a basic dashboard's region. */ + widgetPictures?: Record; } const promo = window.__ODS_PROMO__; +const widgetPlacement = (item: WidgetItem): string => { + const { x, y, width, height } = item.frame; + const box = `x="${x}" y="${y}" width="${width}" height="${height}"`; + const picture = promo?.widgetPictures?.[item.widget.type]; + if (picture) return ``; + const label = `x="${x + width / 2}" y="${y + height / 2}" text-anchor="middle"`; + return [ + ``, + `${item.widget.type}`, + ].join(""); +}; + +/** A basic dashboard: the background, and a widget in each region that has one. */ +const basicPreview = (dashboard: Dashboard): string => { + const { width, height, background } = dashboard.display; + const widgets = withRegionFrames(dashboard).items.flatMap((item) => + item.kind === "widget" ? [widgetPlacement(item)] : [] + ); + const svg = `${widgets.join("")}`; + return `data:image/svg+xml,${encodeURIComponent(svg)}`; +}; + const preview = (dashboard: Dashboard): string => { const promoImage = promo?.images?.[dashboard.id]; if (promoImage) return promoImage; + if (dashboard.mode === "basic") return basicPreview(dashboard); const { width, height } = dashboard.display; const svg = `${dashboard.items.length ? "Kitchen" : ""}21.4 °C`; return `data:image/svg+xml,${encodeURIComponent(svg)}`; @@ -952,7 +1003,7 @@ const preparedImport = (message: Record): unknown => { const itemBounds = (dashboard: Dashboard): Record => { const result: Record = {}; - collectBounds(dashboard.items, { x: 0, y: 0 }, result); + collectBounds(withRegionFrames(dashboard).items, { x: 0, y: 0 }, result); return result; }; @@ -1140,6 +1191,9 @@ const deleteMockFont = (message: Record): Promise => { return Promise.resolve({ fonts: describeFonts() }); }; +/** What the backend tells the panel a grid may be; the same numbers as `basic_layout.py`. */ +const LAYOUT_LIMITS = { minLines: 1, maxLines: 12, defaultGap: 8, maxGap: 64 }; + /** `?lang=pl` shows the panel in another language, to look at a translation. */ const language = new URLSearchParams(window.location.search).get("lang") ?? "en"; @@ -1176,14 +1230,25 @@ const hass: HomeAssistant = { if (message.type === "opendisplay_studio/bootstrap") { await new Promise((resolve) => window.setTimeout(resolve, 60)); return { - version: "0.2.0", + version: "0.3.0", dashboards, widgets: installedWidgets(hass.language), widgetErrors: widgetErrors(), primitives: primitiveDefinitions, fonts: describeFonts(), + layoutLimits: LAYOUT_LIMITS, } as T; } + if (message.type === "opendisplay_studio/layout_frames") { + const { width, height, padding, layout } = message as unknown as { + width: number; + height: number; + padding: number; + layout: GridLayout; + }; + const display = { ...demoDashboard.display, width, height, padding }; + return mockLayoutFrames({ ...demoDashboard, display, layout }) as T; + } if (message.type === "opendisplay_studio/list_fonts") { return { fonts: describeFonts() } as T; } diff --git a/frontend-src/src/display-shape.test.ts b/frontend-src/src/display-shape.test.ts new file mode 100644 index 0000000..14100de --- /dev/null +++ b/frontend-src/src/display-shape.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import { displayShape, fitScale, inkView } from "./display-shape"; + +const BOX = { width: 240, height: 92 }; + +describe("displayShape", () => { + it("keeps the proportions of the device as it is built", () => { + const shape = displayShape({ width: 800, height: 480, rotation: 0 }, BOX); + + expect(shape.screen.width / shape.screen.height).toBeCloseTo(800 / 480); + }); + + it("fits the box, housing included", () => { + const wide = displayShape({ width: 1200, height: 100, rotation: 0 }, BOX); + const tall = displayShape({ width: 100, height: 1200, rotation: 0 }, BOX); + + expect(wide.housing.width).toBeLessThanOrEqual(BOX.width); + expect(tall.housing.height).toBeLessThanOrEqual(BOX.height); + }); + + it("builds a display turned a quarter as the wide device it is", () => { + const portrait = displayShape( + { width: 480, height: 800, rotation: 90 }, + BOX + ); + const landscape = displayShape( + { width: 800, height: 480, rotation: 0 }, + BOX + ); + + expect(portrait.housing).toEqual(landscape.housing); + }); +}); + +describe("fitScale", () => { + const shape = displayShape({ width: 800, height: 480, rotation: 0 }, BOX); + + it("does not shrink a device that is level or upside down", () => { + expect(fitScale(shape.housing, 0, BOX)).toBeCloseTo(1); + expect(fitScale(shape.housing, 180, BOX)).toBeCloseTo(1); + }); + + it("shrinks a wide device turned on its side to the height of the box", () => { + const scale = fitScale(shape.housing, 90, BOX); + + expect(scale).toBeLessThan(1); + expect(shape.housing.width * scale).toBeCloseTo(BOX.height); + }); + + it("shrinks it most between the two", () => { + expect(fitScale(shape.housing, 45, BOX)).toBeLessThan( + fitScale(shape.housing, 90, BOX) + ); + }); +}); + +describe("inkView", () => { + const screen = { width: 160, height: 90 }; + + it("is the screen itself when it is level or upside down", () => { + for (const degrees of [0, 180]) { + const view = inkView(screen, degrees); + expect(view.width).toBeCloseTo(screen.width); + expect(view.height).toBeCloseTo(screen.height); + } + }); + + it("is the screen on its side when it is turned a quarter", () => { + for (const degrees of [90, 270]) { + const view = inkView(screen, degrees); + expect(view.width).toBeCloseTo(screen.height); + expect(view.height).toBeCloseTo(screen.width); + } + }); + + it("always fits inside the screen, even halfway round", () => { + const view = inkView(screen, 45); + const reach = (view.width + view.height) * Math.SQRT1_2; + + expect(reach).toBeLessThanOrEqual(screen.width + 0.001); + expect(reach).toBeLessThanOrEqual(screen.width); + }); +}); diff --git a/frontend-src/src/display-shape.ts b/frontend-src/src/display-shape.ts new file mode 100644 index 0000000..33af0b3 --- /dev/null +++ b/frontend-src/src/display-shape.ts @@ -0,0 +1,96 @@ +import type { Rotation } from "./types"; + +export interface Size { + width: number; + height: number; +} + +/** + * A display drawn for a picture: its housing and its screen as the device is built, before + * it is turned. All sizes are in the units of the box it was fitted into. + */ +export interface DisplayShape { + housing: Size; + screen: Size; + bezel: number; +} + +const BEZEL_RATIO = 0.03; +const MIN_BEZEL = 2.5; +const MAX_BEZEL = 5; + +const clamp = (value: number, low: number, high: number): number => + Math.min(high, Math.max(low, value)); + +const isSideways = (turn: Rotation): boolean => turn === 90 || turn === 270; + +const asBuilt = (size: Size, turn: Rotation): Size => + isSideways(turn) ? { width: size.height, height: size.width } : size; + +/** + * The display as the device is built, as large as the box takes it. `display` is the + * canvas of the dashboard, so a display turned a quarter is already taller than it is wide; + * the device itself is wider, and turning it is what makes the canvas. + */ +export const displayShape = ( + display: Size & { rotation: Rotation }, + box: Size +): DisplayShape => { + const built = asBuilt(display, display.rotation); + const bezel = clamp( + Math.min(box.width, box.height) * BEZEL_RATIO, + MIN_BEZEL, + MAX_BEZEL + ); + const scale = Math.min( + (box.width - 2 * bezel) / built.width, + (box.height - 2 * bezel) / built.height + ); + const screen = { width: built.width * scale, height: built.height * scale }; + return { + housing: { + width: screen.width + 2 * bezel, + height: screen.height + 2 * bezel, + }, + screen, + bezel, + }; +}; + +const radians = (degrees: number): number => (degrees * Math.PI) / 180; + +/** The size of the smallest upright box around a box turned by `degrees`. */ +const turnedBounds = (size: Size, degrees: number): Size => { + const cos = Math.abs(Math.cos(radians(degrees))); + const sin = Math.abs(Math.sin(radians(degrees))); + return { + width: size.width * cos + size.height * sin, + height: size.width * sin + size.height * cos, + }; +}; + +/** + * How much a device turned by `degrees` is shrunk to stay inside the box: not at all when + * it is level or upside down, and the most when it is halfway between. + */ +export const fitScale = (housing: Size, degrees: number, box: Size): number => { + const bounds = turnedBounds(housing, degrees); + return Math.min(1, box.width / bounds.width, box.height / bounds.height); +}; + +/** + * The largest upright box that fits inside a screen turned by `degrees`. The picture on the + * screen is drawn in it, so the content stays upright and inside the screen all the way + * round: it is the screen's own size when level, and the screen turned on its side at 90°. + */ +export const inkView = (screen: Size, degrees: number): Size => { + const cos = Math.abs(Math.cos(radians(degrees))); + const sin = Math.abs(Math.sin(radians(degrees))); + const bounds = turnedBounds(screen, degrees); + const aspect = bounds.width / bounds.height; + const width = Math.min( + screen.width / (cos + sin / aspect), + screen.height / (sin + cos / aspect) + ); + return { width, height: width / aspect }; +}; diff --git a/frontend-src/src/events.ts b/frontend-src/src/events.ts index 7159ea9..d02c612 100644 --- a/frontend-src/src/events.ts +++ b/frontend-src/src/events.ts @@ -1,10 +1,13 @@ import type { AlignTarget } from "./align-selection"; import type { CommandId } from "./commands"; import type { DashboardFormData, DashboardSource } from "./dashboards"; -import type { Dashboard, StudioItem, WidgetPick } from "./types"; +import type { CellRectangle } from "./grid-layout"; +import type { Dashboard, DashboardMode, StudioItem, WidgetPick } from "./types"; import type { Viewport } from "./viewport"; export type EditorView = "dashboards" | "design" | "code" | "settings"; +/** The two tabs of the basic editor: cut the screen into regions, then fill them. */ +export type BasicStep = "layout" | "widgets"; export type DashboardAction = "rename" | "duplicate" | "settings" | "delete"; export type DashboardDialog = Exclude; @@ -30,6 +33,7 @@ export interface OdsEventMap { "new-dashboard-change": { value: Partial }; "new-dashboard-close": undefined; "new-dashboard-source": { source: DashboardSource }; + "new-dashboard-mode": { mode: DashboardMode }; "new-dashboard-device": { deviceId: string }; "new-dashboard-profile": { profileId: string }; "dashboard-create": undefined; @@ -93,6 +97,13 @@ export interface OdsEventMap { "zoom-step": { direction: 1 | -1 }; "zoom-reset": undefined; "zoom-fit": undefined; + // basic editor + "basic-step-change": { step: BasicStep }; + "region-select": { regionId: string }; + /** Two cells were chosen; the regions between them are to become one. */ + "regions-merge": { rectangle: CellRectangle }; + "region-split": { regionId: string }; + "grid-change": { columns?: number; rows?: number; gap?: number }; // structure and inspector "layers-reorder": { itemId: string; diff --git a/frontend-src/src/grid-layout.test.ts b/frontend-src/src/grid-layout.test.ts new file mode 100644 index 0000000..dec3604 --- /dev/null +++ b/frontend-src/src/grid-layout.test.ts @@ -0,0 +1,258 @@ +import { describe, expect, it } from "vitest"; +import { + canMerge, + cellsOf, + evenLayout, + mergeRegions, + rectangleBetween, + regionAt, + regionIdAtPoint, + regionLetter, + regionsIn, + resizeGrid, + splitRegion, +} from "./grid-layout"; + +const ids = (regions: { id: string }[]): string[] => + regions.map((region) => region.id); + +describe("evenLayout", () => { + it("gives every cell a region of its own, in reading order", () => { + const layout = evenLayout(3, 2, 8); + + expect(layout.regions).toHaveLength(6); + expect(layout.regions.map((r) => [r.row, r.column])).toEqual([ + [0, 0], + [0, 1], + [0, 2], + [1, 0], + [1, 1], + [1, 2], + ]); + expect(new Set(ids(layout.regions)).size).toBe(6); + }); +}); + +describe("rectangleBetween", () => { + it("is the same whichever corner is given first", () => { + const forwards = rectangleBetween( + { row: 0, column: 1 }, + { row: 1, column: 2 } + ); + const backwards = rectangleBetween( + { row: 1, column: 2 }, + { row: 0, column: 1 } + ); + + expect(forwards).toEqual({ row: 0, column: 1, rowSpan: 2, columnSpan: 2 }); + expect(backwards).toEqual(forwards); + }); +}); + +describe("mergeRegions", () => { + it("joins the regions in a rectangle into one that covers all their cells", () => { + const layout = evenLayout(2, 2, 8); + const rectangle = rectangleBetween( + { row: 0, column: 0 }, + { row: 0, column: 1 } + ); + + const { layout: merged, removedRegionIds } = mergeRegions( + layout, + rectangle, + new Set() + ); + + expect(merged.regions).toHaveLength(3); + expect(regionAt(merged, { row: 0, column: 1 })).toEqual( + regionAt(merged, { row: 0, column: 0 }) + ); + expect(removedRegionIds).toHaveLength(1); + }); + + it("keeps the region that holds a widget and releases the others", () => { + const layout = evenLayout(2, 1, 8); + const second = layout.regions[1].id; + const rectangle = rectangleBetween( + { row: 0, column: 0 }, + { row: 0, column: 1 } + ); + + const { layout: merged, removedRegionIds } = mergeRegions( + layout, + rectangle, + new Set([second]) + ); + + expect(ids(merged.regions)).toEqual([second]); + expect(removedRegionIds).toEqual([layout.regions[0].id]); + }); + + it("refuses a rectangle that would cut a larger region in two", () => { + const wide = mergeRegions( + evenLayout(3, 1, 8), + rectangleBetween({ row: 0, column: 0 }, { row: 0, column: 1 }), + new Set() + ).layout; + + const cutting = rectangleBetween( + { row: 0, column: 1 }, + { row: 0, column: 2 } + ); + + expect(canMerge(wide, cutting)).toBe(false); + expect( + canMerge( + wide, + rectangleBetween({ row: 0, column: 0 }, { row: 0, column: 2 }) + ) + ).toBe(true); + }); + + it("does not merge a single cell with itself", () => { + const layout = evenLayout(2, 2, 8); + + expect( + canMerge( + layout, + rectangleBetween({ row: 1, column: 1 }, { row: 1, column: 1 }) + ) + ).toBe(false); + }); +}); + +describe("splitRegion", () => { + it("cuts a region back into cells and leaves its id on the first one", () => { + const layout = evenLayout(2, 2, 8); + const merged = mergeRegions( + layout, + rectangleBetween({ row: 0, column: 0 }, { row: 1, column: 1 }), + new Set() + ).layout; + const [only] = merged.regions; + + const split = splitRegion(merged, only.id); + + expect(split.regions).toHaveLength(4); + expect(regionAt(split, { row: 0, column: 0 })?.id).toBe(only.id); + expect( + split.regions.every((r) => r.rowSpan === 1 && r.columnSpan === 1) + ).toBe(true); + }); +}); + +describe("resizeGrid", () => { + it("keeps the regions that still fit and fills the new cells", () => { + const layout = evenLayout(2, 2, 8); + + const { layout: grown, removedRegionIds } = resizeGrid(layout, 3, 2); + + expect(grown.regions).toHaveLength(6); + expect(removedRegionIds).toEqual([]); + expect(ids(grown.regions)).toEqual( + expect.arrayContaining(ids(layout.regions)) + ); + }); + + it("takes away the regions the new edge would cut", () => { + const merged = mergeRegions( + evenLayout(2, 1, 8), + rectangleBetween({ row: 0, column: 0 }, { row: 0, column: 1 }), + new Set() + ).layout; + + const { layout: shrunk, removedRegionIds } = resizeGrid(merged, 1, 1); + + expect(removedRegionIds).toEqual([merged.regions[0].id]); + expect(shrunk.regions).toHaveLength(1); + expect(cellsOf(shrunk.regions[0])).toEqual([{ row: 0, column: 0 }]); + }); + + it("covers every cell of the new grid exactly once", () => { + const { layout } = resizeGrid(evenLayout(3, 3, 8), 2, 4); + + const covered = layout.regions.flatMap((region) => cellsOf(region)); + expect(covered).toHaveLength(8); + expect( + regionsIn(layout, { row: 0, column: 0, rowSpan: 4, columnSpan: 2 }) + ).toHaveLength(8); + }); +}); + +describe("regionIdAtPoint", () => { + const frames = { + cells: [], + regions: [ + { id: "left", x: 0, y: 0, width: 100, height: 100 }, + { id: "right", x: 108, y: 0, width: 100, height: 100 }, + ], + }; + + it("finds the region a point lies in", () => { + expect(regionIdAtPoint(frames, { x: 150, y: 50 })).toBe("right"); + }); + + it("finds nothing in the gap between regions", () => { + expect(regionIdAtPoint(frames, { x: 104, y: 50 })).toBeUndefined(); + }); +}); + +describe("regionLetter", () => { + it("names regions A to Z and then AA, AB …", () => { + expect(regionLetter(0)).toBe("A"); + expect(regionLetter(25)).toBe("Z"); + expect(regionLetter(26)).toBe("AA"); + expect(regionLetter(27)).toBe("AB"); + expect(regionLetter(51)).toBe("AZ"); + expect(regionLetter(52)).toBe("BA"); + }); +}); + +describe("the order regions are named in", () => { + type Layout = ReturnType; + type Corner = [number, number]; + + const join = (layout: Layout, from: Corner, to: Corner) => + mergeRegions( + layout, + rectangleBetween( + { row: from[0], column: from[1] }, + { row: to[0], column: to[1] } + ), + new Set() + ).layout; + + const firstCells = (layout: Layout): string[] => + layout.regions.map((region) => `${region.row},${region.column}`); + + it("follows the order the regions were made in, not where they lie", () => { + let layout = evenLayout(4, 2, 8); + layout = join(layout, [1, 0], [1, 1]); + layout = join(layout, [0, 0], [0, 1]); + layout = join(layout, [0, 3], [1, 3]); + layout = join(layout, [0, 2], [1, 2]); + + expect(firstCells(layout)).toEqual(["1,0", "0,0", "0,3", "0,2"]); + }); + + it("keeps a region in its place when another is made", () => { + let layout = evenLayout(3, 1, 8); + layout = join(layout, [0, 1], [0, 2]); + const first = layout.regions[0].id; + + layout = evenLayout(4, 1, 8); + layout = join(layout, [0, 2], [0, 3]); + const joined = layout.regions[0].id; + layout = join(layout, [0, 0], [0, 1]); + + expect(first).toBeDefined(); + expect(layout.regions[0].id).toBe(joined); + }); + + it("puts the cells nobody joined after the joined regions, in reading order", () => { + let layout = evenLayout(3, 2, 8); + layout = join(layout, [1, 1], [1, 2]); + + expect(firstCells(layout)).toEqual(["1,1", "0,0", "0,1", "0,2", "1,0"]); + }); +}); diff --git a/frontend-src/src/grid-layout.ts b/frontend-src/src/grid-layout.ts new file mode 100644 index 0000000..55dcf23 --- /dev/null +++ b/frontend-src/src/grid-layout.ts @@ -0,0 +1,230 @@ +import type { GridLayout, GridRegion, LayoutFrames } from "./types"; + +export interface CellPosition { + row: number; + column: number; +} + +/** The cells a region covers, as the first cell and how far it reaches. */ +export type CellRectangle = Pick< + GridRegion, + "row" | "column" | "rowSpan" | "columnSpan" +>; + +/** A layout after an edit, with the regions the edit took away. */ +export interface LayoutChange { + layout: GridLayout; + removedRegionIds: string[]; +} + +const LETTERS = 26; + +/** The name a region is shown by: A, B … Z, then AA, AB …, in reading order. */ +export const regionLetter = (index: number): string => { + const letter = String.fromCharCode(65 + (index % LETTERS)); + return index < LETTERS + ? letter + : regionLetter(Math.floor(index / LETTERS) - 1) + letter; +}; + +const REGION_PREFIX = "region-"; + +const unusedRegionId = (used: Set): string => { + let number = 1; + while (used.has(`${REGION_PREFIX}${number}`)) { + number += 1; + } + const id = `${REGION_PREFIX}${number}`; + used.add(id); + return id; +}; + +const isJoined = (region: GridRegion): boolean => + region.rowSpan > 1 || region.columnSpan > 1; + +const readingOrder = (first: GridRegion, second: GridRegion): number => + first.row - second.row || first.column - second.column; + +export const cellsOf = (region: CellRectangle): CellPosition[] => { + const cells: CellPosition[] = []; + for (let row = region.row; row < region.row + region.rowSpan; row += 1) { + for ( + let column = region.column; + column < region.column + region.columnSpan; + column += 1 + ) { + cells.push({ row, column }); + } + } + return cells; +}; + +export const regionAt = ( + layout: GridLayout, + cell: CellPosition +): GridRegion | undefined => + layout.regions.find((region) => + cellsOf(region).some( + (own) => own.row === cell.row && own.column === cell.column + ) + ); + +/** The rectangle of cells between two corners, whichever way they are given. */ +export const rectangleBetween = ( + from: CellPosition, + to: CellPosition +): CellRectangle => ({ + row: Math.min(from.row, to.row), + column: Math.min(from.column, to.column), + rowSpan: Math.abs(from.row - to.row) + 1, + columnSpan: Math.abs(from.column - to.column) + 1, +}); + +/** The regions of the layout, and a one-cell region in each cell none of them covers. */ +const fillGaps = (layout: GridLayout): GridRegion[] => { + const regions = [...layout.regions]; + const used = new Set(regions.map((region) => region.id)); + for (let row = 0; row < layout.rows; row += 1) { + for (let column = 0; column < layout.columns; column += 1) { + if (!regionAt({ ...layout, regions }, { row, column })) { + regions.push({ + id: unusedRegionId(used), + row, + column, + rowSpan: 1, + columnSpan: 1, + }); + } + } + } + return inCreationOrder(regions); +}; + +/** + * The order regions are named in: the ones joined by hand first, in the order they were + * made, then the cells nobody joined, in reading order. A region keeps its place when + * another is made, so its letter does not change under the user. + */ +const inCreationOrder = (regions: GridRegion[]): GridRegion[] => [ + ...regions.filter(isJoined), + ...regions.filter((region) => !isJoined(region)).sort(readingOrder), +]; + +/** Every cell of the grid as a region of its own. */ +export const evenLayout = ( + columns: number, + rows: number, + gap: number +): GridLayout => ({ + columns, + rows, + gap, + regions: fillGaps({ columns, rows, gap, regions: [] }), +}); + +const isInside = (region: GridRegion, rectangle: CellRectangle): boolean => + region.row >= rectangle.row && + region.column >= rectangle.column && + region.row + region.rowSpan <= rectangle.row + rectangle.rowSpan && + region.column + region.columnSpan <= rectangle.column + rectangle.columnSpan; + +const touches = (region: GridRegion, rectangle: CellRectangle): boolean => + region.row < rectangle.row + rectangle.rowSpan && + region.row + region.rowSpan > rectangle.row && + region.column < rectangle.column + rectangle.columnSpan && + region.column + region.columnSpan > rectangle.column; + +/** The regions a rectangle of cells covers, in reading order. */ +export const regionsIn = ( + layout: GridLayout, + rectangle: CellRectangle +): GridRegion[] => + layout.regions + .filter((region) => touches(region, rectangle)) + .sort(readingOrder); + +/** + * Whether the rectangle can become one region: it must take in more than one region and + * cut none of them in two. + */ +export const canMerge = ( + layout: GridLayout, + rectangle: CellRectangle +): boolean => { + const covered = regionsIn(layout, rectangle); + return covered.length > 1 && covered.every((r) => isInside(r, rectangle)); +}; + +/** + * Joins the regions inside a rectangle. The new region keeps the id of the first of them + * that holds a widget, or else of the first one; the widgets of the others are released. + */ +export const mergeRegions = ( + layout: GridLayout, + rectangle: CellRectangle, + occupied: ReadonlySet +): LayoutChange => { + const covered = regionsIn(layout, rectangle); + const kept = covered.find((region) => occupied.has(region.id)) ?? covered[0]; + const removed = covered.filter((region) => region.id !== kept.id); + const regions = layout.regions + .filter((region) => !covered.includes(region)) + .concat({ id: kept.id, ...rectangle }); + return { + layout: { ...layout, regions: inCreationOrder(regions) }, + removedRegionIds: removed.map((region) => region.id), + }; +}; + +/** Cuts a region back into cells; the first cell keeps the id and so the widget. */ +export const splitRegion = ( + layout: GridLayout, + regionId: string +): GridLayout => { + const region = layout.regions.find((candidate) => candidate.id === regionId); + if (!region) { + return layout; + } + const others = layout.regions.filter((candidate) => candidate !== region); + const first: GridRegion = { + id: region.id, + row: region.row, + column: region.column, + rowSpan: 1, + columnSpan: 1, + }; + const regions = fillGaps({ ...layout, regions: [...others, first] }); + return { ...layout, regions }; +}; + +/** + * Gives the grid another size. Regions that still lie inside it stay; the ones the new + * edge would cut are taken away, and every cell left uncovered becomes a region. + */ +export const resizeGrid = ( + layout: GridLayout, + columns: number, + rows: number +): LayoutChange => { + const whole = { row: 0, column: 0, rowSpan: rows, columnSpan: columns }; + const kept = layout.regions.filter((region) => isInside(region, whole)); + const removed = layout.regions.filter((region) => !kept.includes(region)); + const resized = { ...layout, columns, rows, regions: kept }; + return { + layout: { ...resized, regions: fillGaps(resized) }, + removedRegionIds: removed.map((region) => region.id), + }; +}; + +/** The id of the region whose frame contains a point of the display, if there is one. */ +export const regionIdAtPoint = ( + frames: LayoutFrames, + point: { x: number; y: number } +): string | undefined => + frames.regions.find( + (frame) => + point.x >= frame.x && + point.x < frame.x + frame.width && + point.y >= frame.y && + point.y < frame.y + frame.height + )?.id; diff --git a/frontend-src/src/ods-app.ts b/frontend-src/src/ods-app.ts index 4c5da5e..656c49b 100644 --- a/frontend-src/src/ods-app.ts +++ b/frontend-src/src/ods-app.ts @@ -94,7 +94,24 @@ import { chooseFile, downloadFile, isMacPlatform, isTypingTarget } from "./dom"; import { exportFileName, parseJson } from "./dashboard-file"; import { messageFrom } from "./error-message"; import { rememberFonts } from "./font-catalog"; -import { type DashboardDialog, type EditorView, type OdsEvent } from "./events"; +import { + type BasicStep, + type DashboardDialog, + type EditorView, + type OdsEvent, +} from "./events"; +import { + mergeRegionsIn, + placeWidget, + regionForNewWidget, + resizeGridIn, + setGapIn, + splitRegionIn, + widgetInRegion, + widgetsOf, + withMode, +} from "./basic-ops"; +import { regionAt, regionIdAtPoint } from "./grid-layout"; import { snapToGrid, workingArea } from "./geometry"; import { History } from "./history"; import { strings } from "./strings"; @@ -119,6 +136,8 @@ import type { DisplayDevice, HomeAssistant, ImportPlan, + LayoutFrames, + LayoutLimits, PrimitiveDefinition, StudioItem, WidgetLoadError, @@ -131,7 +150,9 @@ import "./ods-confirm-dialog"; import "./ods-import-dialog"; import "./ods-context-menu"; import "./ods-shortcuts-dialog"; +import "./ods-basic-steps"; import "./ods-gallery"; +import "./ods-layout-panel"; import "./ods-settings"; import "./ods-header"; import "./ods-inspector"; @@ -261,6 +282,12 @@ export class OdsApp extends LitElement { ); overflow: hidden; } + .basic-left { + min-width: 0; + min-height: 0; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + } .dashboard-empty { position: relative; height: 100%; @@ -328,6 +355,11 @@ export class OdsApp extends LitElement { @state() private displayDevices: DisplayDevice[] = []; @state() private dashboardDialog?: DashboardDialog; @state() private dashboardDraft?: Dashboard; + @state() private layoutLimits?: LayoutLimits; + /** Where the cells and regions of the open basic dashboard are on its display. */ + @state() private layoutFrames?: LayoutFrames; + @state() private basicStep: BasicStep = "layout"; + @state() private selectedRegionId = ""; @query("ods-canvas") private canvas?: OdsCanvas; @@ -335,6 +367,8 @@ export class OdsApp extends LitElement { private stateTimer?: number; private clockTimer?: number; private previewRequest = 0; + private layoutRequest = 0; + private layoutKey = ""; private bootstrapStarted = false; private readonly history = new History(); @@ -361,6 +395,7 @@ export class OdsApp extends LitElement { this.ensureBootstrap(); this.refreshOnStateChange(changed.get("hass")); } + void this.refreshLayoutFrames(); } disconnectedCallback(): void { super.disconnectedCallback(); @@ -390,6 +425,7 @@ export class OdsApp extends LitElement { this.widgetErrors = data.widgetErrors; this.primitives = data.primitives; rememberFonts(data.fonts); + this.layoutLimits = data.layoutLimits; this.current = undefined; this.preview = undefined; this.view = "dashboards"; @@ -461,6 +497,7 @@ export class OdsApp extends LitElement { this.dirty = false; this.newDashboardOpen = false; this.view = "design"; + this.basicStep = "layout"; this.clearHistory(); await this.composePreview(); await this.updateComplete; @@ -577,6 +614,8 @@ export class OdsApp extends LitElement { if (this.dirty && !window.confirm(strings.app.discardChanges)) return; this.current = structuredClone(dashboard); this.clearSelection(); + this.selectedRegionId = ""; + this.basicStep = widgetsOf(dashboard).length > 0 ? "widgets" : "layout"; this.dirty = false; this.clearHistory(); this.view = "design"; @@ -808,6 +847,7 @@ export class OdsApp extends LitElement { entered: this.enteredGroupId !== "", canPaste: (this.clipboard?.items.length ?? 0) > 0, dirty: this.dirty, + basic: this.isBasic, }; } @@ -1183,10 +1223,16 @@ export class OdsApp extends LitElement { if (view === "code" && !this.preview) void this.composePreview(); } private selectItem(itemId: string): void { - this.setSelection(itemId ? [itemId] : []); + if (!itemId) { + this.clearSelection(); + return; + } + this.setSelection([itemId]); } + /** Nothing is selected: no element, and no region of a basic dashboard either. */ private clearSelection(): void { this.setSelection([]); + this.selectedRegionId = ""; } private setSelection(ids: string[]): void { const same = @@ -1275,6 +1321,10 @@ export class OdsApp extends LitElement { } private addFromCatalog(value: string): void { if (!this.current) return; + if (this.isBasic) { + this.addToRegion(value, this.selectedRegionId); + return; + } const { x, y } = catalogCascadePosition(this.current, this.snapEnabled); this.addAt(value, x, y); } @@ -1286,6 +1336,10 @@ export class OdsApp extends LitElement { const dashboard = this.current; const point = this.canvas?.displayPointAt(clientX, clientY); if (!dashboard || !point) return; + if (this.isBasic) { + this.addToRegion(value, this.regionAtPoint(point)); + return; + } const area = workingArea(dashboard); const x = clamp( snapToGrid(point.x, dashboard, this.snapEnabled), @@ -1307,6 +1361,104 @@ export class OdsApp extends LitElement { this.addAt(value, x, y, target?.id); } + // --- the basic editor -------------------------------------------------------- + + private get isBasic(): boolean { + return this.current?.mode === "basic"; + } + + private regionAtPoint(point: { x: number; y: number }): string { + if (!this.layoutFrames) return ""; + return regionIdAtPoint(this.layoutFrames, point) ?? ""; + } + + /** Asks the backend where the cells and regions are, whenever the grid or display changes. */ + private async refreshLayoutFrames(): Promise { + const dashboard = this.current; + const layout = dashboard?.layout; + if (!this.hass || !dashboard || !layout) { + this.layoutFrames = undefined; + this.layoutKey = ""; + return; + } + const { width, height, padding } = dashboard.display; + const key = JSON.stringify([width, height, padding, layout]); + if (key === this.layoutKey) return; + this.layoutKey = key; + const request = (this.layoutRequest += 1); + try { + const frames = await api.layoutFrames( + this.hass, + dashboard.display, + layout + ); + if (request === this.layoutRequest) this.layoutFrames = frames; + } catch (error) { + this.error = messageFrom(error, strings.app.previewFailed); + } + } + + /** The widget goes to the chosen region, or to the first free one. */ + private addToRegion(value: string, chosenRegionId: string): void { + const dashboard = this.current; + const layout = dashboard?.layout; + const [kind, type] = value.split(":"); + const definition = this.widgets.find((widget) => widget.id === type); + if (!dashboard || !layout || kind !== "widget" || !definition) return; + const region = regionForNewWidget(dashboard, layout, chosenRegionId); + const frame = this.layoutFrames?.regions.find((r) => r.id === region?.id); + if (!region || !frame) { + this.showNotice(strings.basic.noEmptyRegion); + return; + } + this.mutate((next) => placeWidget(next, definition, region, frame)); + this.selectRegion(region.id); + void this.composePreview(); + } + + private selectRegion(regionId: string): void { + const widget = this.current && widgetInRegion(this.current, regionId); + this.setSelection(widget ? [widget.id] : []); + this.selectedRegionId = regionId; + } + + private onBasicStepChange(event: OdsEvent<"basic-step-change">): void { + this.basicStep = event.detail.step; + } + + private onRegionSelect(event: OdsEvent<"region-select">): void { + this.selectRegion(event.detail.regionId); + } + + private onRegionsMerge(event: OdsEvent<"regions-merge">): void { + const { rectangle } = event.detail; + this.mutate((next) => mergeRegionsIn(next, rectangle)); + const layout = this.current?.layout; + const merged = layout && regionAt(layout, rectangle); + this.selectRegion(merged?.id ?? ""); + void this.composePreview(); + } + + private onRegionSplit(event: OdsEvent<"region-split">): void { + this.mutate((next) => splitRegionIn(next, event.detail.regionId)); + this.selectRegion(event.detail.regionId); + } + + private onGridChange(event: OdsEvent<"grid-change">): void { + const { columns, rows, gap } = event.detail; + this.mutate((next) => { + const layout = next.layout; + if (!layout) return; + if (gap !== undefined) setGapIn(next, gap); + resizeGridIn(next, { + columns: columns ?? layout.columns, + rows: rows ?? layout.rows, + }); + }); + if (this.current) this.keepExistingSelection(this.current); + void this.composePreview(); + } + // --- groups ---------------------------------------------------------------- private groupSelection(itemIds: string[]): void { @@ -1390,6 +1542,11 @@ export class OdsApp extends LitElement { ); } + private onNewDashboardMode(event: OdsEvent<"new-dashboard-mode">): void { + const gap = this.layoutLimits?.defaultGap ?? 8; + this.newDashboard = withMode(this.newDashboard, event.detail.mode, gap); + } + private onNewDashboardSource(event: OdsEvent<"new-dashboard-source">): void { this.chooseNewDashboardSource(event.detail.source); } @@ -1408,6 +1565,10 @@ export class OdsApp extends LitElement { this.newDashboardOpen = false; } + private onRenameHandled(): void { + this.renameRequestId = ""; + } + private showGallery(): void { this.view = "dashboards"; } @@ -1720,6 +1881,7 @@ export class OdsApp extends LitElement { .saving=${this.saving} @new-dashboard-change=${this.onNewDashboardChange} @new-dashboard-source=${this.onNewDashboardSource} + @new-dashboard-mode=${this.onNewDashboardMode} @new-dashboard-device=${this.onNewDashboardDevice} @new-dashboard-profile=${this.onNewDashboardProfile} @new-dashboard-close=${this.closeNewDashboard} @@ -1766,91 +1928,167 @@ export class OdsApp extends LitElement { `; } - private renderDesign(dashboard: Dashboard): TemplateResult { - const layoutStyle = styleMap({ + private layoutStyle(): ReturnType { + return styleMap({ "--toolbox-width": this.leftCollapsed ? "48px" : "255px", "--inspector-width": this.rightCollapsed ? "48px" : `${this.inspectorWidth}px`, }); + } + + private renderLibrary(widgetsOnly: boolean): TemplateResult { + return html` + + `; + } + + private renderCanvas(dashboard: Dashboard): TemplateResult { + return html` + 0} + .canRedo=${this.redoCount > 0} + .viewport=${this.viewport} + .layoutFrames=${this.layoutFrames} + .basicStep=${this.basicStep} + .selectedRegionId=${this.selectedRegionId} + @viewport-change=${this.onViewportChange} + @items-transform=${this.onItemsTransform} + @item-transform-end=${this.onItemTransformEnd} + @gesture-cancel=${this.onGestureCancel} + @primitive-field-change=${this.onPrimitiveFieldChange} + @snap-toggle=${this.toggleSnap} + @align-target-change=${this.onAlignTargetChange} + @region-select=${this.onRegionSelect} + @regions-merge=${this.onRegionsMerge} + @region-split=${this.onRegionSplit} + > + `; + } + + private renderInspector(dashboard: Dashboard): TemplateResult { + return html` + + `; + } + + private renderDialogs(): TemplateResult { + return html` + ${this.renderDeleteDialog()} ${this.renderImportDialog()} + ${this.renderMenu()} ${this.renderShortcutsDialog()} + `; + } + + private renderDesign(dashboard: Dashboard): TemplateResult { return html`
{ - this.renameRequestId = ""; - }} + @rename-handled=${this.onRenameHandled} @command=${this.onCommand} > - - 0} - .canRedo=${this.redoCount > 0} - .viewport=${this.viewport} - @viewport-change=${this.onViewportChange} - @items-transform=${this.onItemsTransform} - @item-transform-end=${this.onItemTransformEnd} - @gesture-cancel=${this.onGestureCancel} - @primitive-field-change=${this.onPrimitiveFieldChange} - @snap-toggle=${this.toggleSnap} - @align-target-change=${this.onAlignTargetChange} - > - + ${this.renderLibrary(false)} ${this.renderCanvas(dashboard)} + ${this.renderInspector(dashboard)}
- ${this.renderDeleteDialog()} ${this.renderImportDialog()} - ${this.renderMenu()} ${this.renderShortcutsDialog()} + ${this.renderDialogs()} + `; + } + + /** The left panel of the basic editor: the tabs, and below them the tab's own tools. */ + private renderBasicLeft(dashboard: Dashboard): TemplateResult { + return html` +
+ + ${ + this.basicStep === "layout" + ? html` + + ` + : this.renderLibrary(true) + } +
+ `; + } + + /** + * The basic editor: the same canvas, properties and library as the advanced one, with + * regions on the canvas, no structure tree and no arrange bar. + */ + private renderBasicDesign(dashboard: Dashboard): TemplateResult { + return html` +
+ ${this.renderBasicLeft(dashboard)} ${this.renderCanvas(dashboard)} + ${this.renderInspector(dashboard)} +
+ ${this.renderDialogs()} `; } @@ -1872,6 +2110,12 @@ export class OdsApp extends LitElement { `; } + private renderEditorBody(dashboard: Dashboard): TemplateResult { + return dashboard.mode === "basic" + ? this.renderBasicDesign(dashboard) + : this.renderDesign(dashboard); + } + private renderEditor(dashboard: Dashboard): TemplateResult { return html`
@@ -1899,7 +2143,7 @@ export class OdsApp extends LitElement { ? html` ` - : this.renderDesign(dashboard) + : this.renderEditorBody(dashboard) }
`; diff --git a/frontend-src/src/ods-basic-steps.ts b/frontend-src/src/ods-basic-steps.ts new file mode 100644 index 0000000..ae6d47f --- /dev/null +++ b/frontend-src/src/ods-basic-steps.ts @@ -0,0 +1,67 @@ +import { css, html, LitElement, type TemplateResult } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { emit } from "./events"; +import type { BasicStep } from "./events"; +import { strings } from "./strings"; +import { baseStyles, chromeStyles, fieldStyles } from "./studio-styles"; + +/** The two tabs of the basic editor: draw the regions, then put widgets into them. */ +@customElement("ods-basic-steps") +export class OdsBasicSteps extends LitElement { + static styles = [ + baseStyles, + chromeStyles, + fieldStyles, + css` + :host { + display: block; + padding: var(--ha-space-2, 8px) 9px; + border-inline-end: 1px solid var(--studio-border); + border-bottom: 1px solid var(--studio-border); + background: var(--studio-surface); + } + .segmented { + height: 32px; + } + .segmented button { + font-size: 12px; + } + .segmented button[aria-pressed="true"] { + background: var(--studio-accent); + } + `, + ]; + + @property() public step: BasicStep = "layout"; + + private renderStep(step: BasicStep, label: string): TemplateResult { + return html` + + `; + } + + protected render(): TemplateResult { + return html` +
+ ${this.renderStep("layout", strings.basic.stepLayout)} + ${this.renderStep("widgets", strings.basic.stepWidgets)} +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ods-basic-steps": OdsBasicSteps; + } +} diff --git a/frontend-src/src/ods-canvas.ts b/frontend-src/src/ods-canvas.ts index 599ca3c..4d1309d 100644 --- a/frontend-src/src/ods-canvas.ts +++ b/frontend-src/src/ods-canvas.ts @@ -10,7 +10,7 @@ import { type CommandId, } from "./commands"; import { isAdditiveClick, isMacPlatform, isTypingTarget } from "./dom"; -import { emit, type OdsEvent } from "./events"; +import { emit, type BasicStep, type OdsEvent } from "./events"; import { snapToGrid, workingArea } from "./geometry"; import { boxBetween, @@ -68,6 +68,7 @@ import type { PreviewState, Dashboard, ItemBounds, + LayoutFrames, PrimitiveDefinition, PrimitiveItem, StudioItem, @@ -83,6 +84,7 @@ import { zoomStep, type Viewport, } from "./viewport"; +import "./ods-region-layer"; import "./ods-zoom-bar"; const GESTURE_THRESHOLD = 3; @@ -140,6 +142,9 @@ export class OdsCanvas extends LitElement { color: var(--studio-text); overflow: hidden; } + .workspace.basic { + grid-template-rows: 42px minmax(0, 1fr); + } .history-controls { flex: none; display: flex; @@ -655,6 +660,11 @@ export class OdsCanvas extends LitElement { @property({ type: Boolean }) public acceptingDrop = false; @property({ type: Boolean }) public canUndo = false; @property({ type: Boolean }) public canRedo = false; + /** Where the cells and regions of a basic dashboard are; the backend lays them out. */ + @property({ attribute: false }) public layoutFrames?: LayoutFrames; + /** The tab of the basic editor, which decides what the regions on the canvas do. */ + @property() public basicStep: BasicStep = "layout"; + @property() public selectedRegionId = ""; /** Pan and zoom live in the shell so they survive a trip to the code view. */ @property({ attribute: false }) public viewport: Viewport = DEFAULT_VIEWPORT; @@ -714,6 +724,11 @@ export class OdsCanvas extends LitElement { }; } + /** A basic dashboard is edited by regions: nothing on the canvas is moved or resized. */ + private get isBasic(): boolean { + return this.dashboard.mode === "basic"; + } + private setViewport(viewport: Viewport): void { emit(this, "viewport-change", viewport); } @@ -789,7 +804,10 @@ export class OdsCanvas extends LitElement { /** A drag that moves the view, wherever the pointer is. */ private onStagePointerDown(event: PointerEvent): void { - if (!this.startsViewDrag(event, true)) return; + if (!this.startsViewDrag(event, true)) { + this.releaseRegion(event); + return; + } event.preventDefault(); let last = event; this.stopGesture?.(); @@ -808,6 +826,16 @@ export class OdsCanvas extends LitElement { }); } + /** Pressing beside the regions lets go of the selected one, as it does for elements. */ + private releaseRegion(event: PointerEvent): void { + if (!this.isBasic || event.button !== 0) return; + const target = event.target; + const onRegions = + target instanceof Element && + target.closest("ods-region-layer, ods-zoom-bar"); + if (!onRegions) emit(this, "region-select", { regionId: "" }); + } + private toggleGrid(): void { this.gridVisible = !this.gridVisible; } @@ -1183,6 +1211,7 @@ export class OdsCanvas extends LitElement { /** Drag out a box on empty canvas to select what it touches; a plain click deselects. */ private onCanvasPointerDown(event: PointerEvent): void { if (this.startsViewDrag(event, true)) return; + if (this.isBasic) return; const start = this.clampedPointAt(event.clientX, event.clientY); if (!start) return; const additive = event.shiftKey; @@ -1229,6 +1258,7 @@ export class OdsCanvas extends LitElement { private onCanvasContextMenu(event: MouseEvent): void { event.preventDefault(); + if (this.isBasic) return; emit(this, "context-menu", { source: "empty", clientX: event.clientX, @@ -1842,59 +1872,99 @@ export class OdsCanvas extends LitElement { `; } - private renderToolbar(): TemplateResult { - const dashboard = this.dashboard; - const { width, height, padding, snapSize } = dashboard.display; - const snapClasses = classMap({ - "tool-toggle": true, - active: this.snapEnabled, - }); - const panClasses = classMap({ + private renderPanToggle(): TemplateResult { + const classes = classMap({ "tool-toggle": true, active: this.panMode, }); - const gridClasses = classMap({ + return html` + + `; + } + + private renderGridToggle(): TemplateResult { + const classes = classMap({ "tool-toggle": true, active: this.gridVisible, }); + return html` + + `; + } + + private renderSnapToggle(): TemplateResult { + const classes = classMap({ + "tool-toggle": true, + active: this.snapEnabled, + }); + return html` + + `; + } + + private renderHistoryControls(): TemplateResult { + return html` +
+ ${this.renderCommandButton("undo", this.historyContext)} + ${this.renderCommandButton("redo", this.historyContext)} +
+ `; + } + + private renderZoomReadout(): TemplateResult { + return html` + ${Math.round(this.viewport.zoom * 100)}% + `; + } + + /** Free placement: layers, pan, the snap grid and snapping. */ + private renderToolbar(): TemplateResult { + const dashboard = this.dashboard; + const { width, height, padding } = dashboard.display; return html`
${strings.common.sizeInPixels(width, height)} ${strings.canvas.layers(countItems(dashboard.items))} ${strings.canvas.padding(padding)} -
- ${this.renderCommandButton("undo", this.historyContext)} - ${this.renderCommandButton("redo", this.historyContext)} -
- - - - - ${Math.round(this.viewport.zoom * 100)}% - + ${this.renderHistoryControls()} ${this.renderPanToggle()} + ${this.renderGridToggle()} ${this.renderSnapToggle()} + ${this.renderZoomReadout()} +
+ `; + } + + /** Regions: the size, undo and redo, pan and zoom; there is nothing to snap to. */ + private renderBasicToolbar(): TemplateResult { + const { width, height, padding } = this.dashboard.display; + return html` +
+ ${strings.common.sizeInPixels(width, height)} + ${strings.canvas.padding(padding)} + ${this.renderHistoryControls()} ${this.renderPanToggle()} + ${this.renderZoomReadout()}
`; } @@ -1938,9 +2008,40 @@ export class OdsCanvas extends LitElement { `; } + private renderRegions(): TemplateResult { + return html` + + `; + } + + /** The elements of an advanced dashboard, with their handles and guides. */ + private renderItems(): TemplateResult { + const dashboard = this.dashboard; + const workingAreaStyle = styleMap({ + ...percentBox(workingArea(dashboard), dashboard.display), + "--snap-size": `${dashboard.display.snapSize * this.viewport.zoom}px`, + }); + return html` + + ${withOffsets(dashboard.items).map((placed) => this.renderPlaced(placed))} + ${this.renderSelectionBox()} ${this.renderMarquee()} + ${this.guides.map((guide) => this.renderGuide(guide))} + ${this.spacing.map((mark) => this.renderSpacing(mark))} + `; + } + private renderStage(): TemplateResult { const dashboard = this.dashboard; - const { width, height, snapSize } = dashboard.display; + const { width, height } = dashboard.display; const { zoom, panX, panY } = this.viewport; const viewportStyle = styleMap({ transform: `translate(${panX}px, ${panY}px) scale(${zoom})`, @@ -1951,10 +2052,6 @@ export class OdsCanvas extends LitElement { // The overlay is drawn inside the zoomed canvas; this brings its sizes back to the screen. "--ui": String(1 / zoom), }); - const workingAreaStyle = styleMap({ - ...percentBox(workingArea(dashboard), dashboard.display), - "--snap-size": `${snapSize * zoom}px`, - }); const stageClasses = classMap({ "canvas-stage": true, "view-drag": this.spaceHeld, @@ -1976,17 +2073,7 @@ export class OdsCanvas extends LitElement { @contextmenu=${this.onCanvasContextMenu} > ${this.renderPreview()} - - ${withOffsets(dashboard.items).map((placed) => - this.renderPlaced(placed) - )} - ${this.renderSelectionBox()} ${this.renderMarquee()} - ${this.guides.map((guide) => this.renderGuide(guide))} - ${this.spacing.map((mark) => this.renderSpacing(mark))} + ${this.isBasic ? this.renderRegions() : this.renderItems()}
- ${this.renderToolbar()} ${this.renderArrangeBar()} ${this.renderStage()} +
+ ${this.isBasic ? this.renderBasicToolbar() : this.renderToolbar()} + ${this.isBasic ? nothing : this.renderArrangeBar()} + ${this.renderStage()}
`; } diff --git a/frontend-src/src/ods-dashboard-card.ts b/frontend-src/src/ods-dashboard-card.ts index c4e06c0..288e0a0 100644 --- a/frontend-src/src/ods-dashboard-card.ts +++ b/frontend-src/src/ods-dashboard-card.ts @@ -16,8 +16,13 @@ import { emit, type DashboardAction, type OdsEvent } from "./events"; import type { ContextMenuEntry } from "./ods-context-menu"; import { strings } from "./strings"; import { baseStyles, chromeStyles, tileStyles } from "./studio-styles"; +import type { Size } from "./display-shape"; import type { Dashboard } from "./types"; import "./ods-context-menu"; +import "./ods-display-preview"; + +/** The field the display is drawn in: the picture area of a card, less its padding. */ +const DISPLAY_BOX: Size = { width: 240, height: 92 }; /** The actions of a card, read when it is drawn so they speak the language of the panel. */ const menu = (): Array => [ @@ -34,7 +39,7 @@ const menu = (): Array => [ { id: "settings", label: strings.gallery.menu.settings, - icon: "mdi:monitor-cog", + icon: "mdi:cog-outline", }, { id: "delete", @@ -81,7 +86,7 @@ export class OdsDashboardCard extends LitElement { display: grid; place-items: center; overflow: hidden; - padding: 23px; + padding: 35px 23px 23px; border-radius: 12px 12px 0 0; background: color-mix( in srgb, @@ -90,54 +95,6 @@ export class OdsDashboardCard extends LitElement { ); pointer-events: none; } - .dashboard-miniature { - position: relative; - width: min(145px, 70%); - max-height: 96px; - overflow: hidden; - border: 2px solid - color-mix(in srgb, var(--dashboard-accent) 22%, var(--studio-border)); - border-radius: 9px; - box-shadow: 0 7px 18px rgba(0, 0, 0, 0.12); - } - .dashboard-miniature > span { - position: absolute; - display: block; - border-radius: 99px; - } - .miniature-title { - left: 12%; - top: 25%; - width: 25%; - height: 4%; - min-height: 3px; - background: color-mix(in srgb, var(--studio-muted) 50%, transparent); - } - .miniature-accent { - right: 12%; - top: 25%; - width: 5px; - height: 5px; - background: var(--dashboard-accent); - } - .miniature-line { - left: 12%; - bottom: 26%; - width: 48%; - height: 4%; - min-height: 3px; - background: color-mix(in srgb, var(--studio-muted) 28%, transparent); - } - .miniature-line.long { - bottom: 39%; - width: 72%; - height: 13%; - background: color-mix( - in srgb, - var(--dashboard-accent) 18%, - var(--studio-surface) - ); - } .dashboard-resolution { position: absolute; right: 11px; @@ -186,22 +143,26 @@ export class OdsDashboardCard extends LitElement { font-weight: 700; pointer-events: auto; } - .dashboard-card-meta { - display: flex; - align-items: center; - gap: 7px; + .dashboard-card-copy small { color: var(--studio-muted); font-size: 10px; } - .dashboard-card-meta > span + span::before { - content: "·"; - margin-right: 7px; - } - .dashboard-card-copy small { - color: var(--studio-muted); + .mode-badge { + position: absolute; + top: var(--ha-space-2, 8px); + inset-inline-start: var(--ha-space-2, 8px); + padding: 1px 7px; + border: 1px solid var(--studio-border); + border-radius: 999px; + background: var(--studio-surface); + color: var(--studio-accent); font-size: 10px; + font-weight: 600; } .palette-dots { + position: absolute; + inset-inline-start: 11px; + bottom: var(--ha-space-2, 8px); display: inline-flex; align-items: center; gap: 2px; @@ -328,22 +289,19 @@ export class OdsDashboardCard extends LitElement { private renderMiniature(): TemplateResult { const { display } = this.dashboard; - const style = styleMap({ - aspectRatio: `${display.width} / ${display.height}`, - background: display.background, - "--dashboard-accent": dashboardAccent(display.palette), - }); return html`
-
- - - - -
- - ${strings.common.size(display.width, display.height)} - + ${this.modeLabel()} + + ${this.renderPaletteDots()} + ${this.resolutionLabel()}
`; } @@ -375,22 +333,34 @@ export class OdsDashboardCard extends LitElement { `; } - private renderMeta(): TemplateResult { - const { display } = this.dashboard; + /** The resolution of the canvas, and the turn when the display is not upright. */ + private resolutionLabel(): string { + const { width, height, rotation } = this.dashboard.display; + const size = strings.common.size(width, height); + return rotation === 0 ? size : `${size} · ${rotation}°`; + } + + private modeLabel(): string { + return this.dashboard.mode === "basic" + ? strings.basic.badgeBasic + : strings.basic.badgeAdvanced; + } + + /** The colors of the display, level with its resolution in the picture's corner. */ + private renderPaletteDots(): TemplateResult { + const { palette } = this.dashboard.display; return html` - - ${strings.common.size(display.width, display.height)} - - ${PALETTE_COLORS[display.palette].map( - (color) => html` - - ` - )} - - ${PALETTE_LABELS[display.palette]} + + ${PALETTE_COLORS[palette].map( + (color) => html` + + ` + )} `; } @@ -420,7 +390,7 @@ export class OdsDashboardCard extends LitElement {
${this.renderMiniature()}
- ${this.renderTitle()} ${this.renderMeta()} + ${this.renderTitle()} ${strings.gallery.updated(dashboardDate(dashboard, this.language))} diff --git a/frontend-src/src/ods-display-preview.ts b/frontend-src/src/ods-display-preview.ts new file mode 100644 index 0000000..5cedf7c --- /dev/null +++ b/frontend-src/src/ods-display-preview.ts @@ -0,0 +1,234 @@ +import { + css, + html, + LitElement, + svg, + type SVGTemplateResult, + type TemplateResult, +} from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { + displayShape, + fitScale, + inkView, + type DisplayShape, + type Size, +} from "./display-shape"; +import { shortestTurn, TURN_DURATION_MS, turnAngleAt } from "./rotation-motion"; +import { strings } from "./strings"; +import type { Rotation } from "./types"; + +/** The field a display is drawn in when the owner does not say. */ +const DEFAULT_BOX: Size = { width: 240, height: 92 }; + +/** + * A drawing of a display in the proportions of its resolution and turned as it is set up: the + * light and the button sit where they are on the housing, so a turned display shows. With + * `turns` a change of `rotation` turns it: a lean back, a swing past, and a settle. + */ +@customElement("ods-display-preview") +export class OdsDisplayPreview extends LitElement { + static styles = css` + :host { + display: block; + } + svg { + display: block; + width: 100%; + height: 100%; + } + .housing { + fill: #35383b; + } + .screen { + stroke: color-mix(in srgb, var(--studio-text, #202124) 30%, transparent); + stroke-width: 0.5; + } + .light { + fill: #4fd1a5; + } + .button { + fill: #8a9096; + } + .skeleton-title { + fill: color-mix(in srgb, var(--studio-muted, #68727a) 50%, transparent); + } + .skeleton-line { + fill: color-mix(in srgb, var(--studio-muted, #68727a) 28%, transparent); + } + .skeleton-block { + fill: color-mix( + in srgb, + var(--display-accent, #202124) 18%, + var(--studio-surface, #fff) + ); + } + .skeleton-accent { + fill: var(--display-accent, #202124); + } + `; + + /** The canvas of the dashboard, so a quarter turn has already swapped them. */ + @property({ type: Number }) public width = 800; + @property({ type: Number }) public height = 480; + @property({ type: Number }) public rotation: Rotation = 0; + @property() public background = "white"; + @property() public accent = "#202124"; + @property({ attribute: false }) public box: Size = DEFAULT_BOX; + /** Whether a change of `rotation` is shown as a turn instead of a jump. */ + @property({ type: Boolean }) public turns = false; + + /** Where the display is turned to now, which is not `rotation` while it is moving. */ + @state() private angle = 0; + /** Where the turn ends; it is not wrapped, so the display never turns the long way. */ + private target = 0; + private frame = 0; + + connectedCallback(): void { + super.connectedCallback(); + this.angle = this.rotation; + this.target = this.rotation; + } + + disconnectedCallback(): void { + super.disconnectedCallback(); + cancelAnimationFrame(this.frame); + } + + protected willUpdate(changed: Map): void { + if (changed.has("rotation") && this.hasUpdated) { + this.turnTo(this.rotation); + } + } + + private turnTo(rotation: Rotation): void { + cancelAnimationFrame(this.frame); + const from = this.angle; + this.target += shortestTurn(this.target, rotation); + if (!this.turns || prefersReducedMotion()) { + this.angle = this.target; + return; + } + const to = this.target; + const started = performance.now(); + const step = (now: number): void => { + const progress = (now - started) / TURN_DURATION_MS; + this.angle = turnAngleAt(from, to, progress); + if (progress < 1) this.frame = requestAnimationFrame(step); + }; + this.frame = requestAnimationFrame(step); + } + + /** The skeleton of a dashboard, drawn upright in the largest box the screen holds. */ + private renderSkeleton(view: Size): SVGTemplateResult { + const left = -view.width / 2; + const top = -view.height / 2; + const barHeight = Math.max(3, view.height * 0.04); + const barTop = top + view.height * 0.25; + return svg` + + + + + `; + } + + private renderHousing(shape: DisplayShape): SVGTemplateResult { + const { housing, screen, bezel } = shape; + return svg` + + + + + `; + } + + protected render(): TemplateResult { + const box = this.box; + const shape = displayShape( + { width: this.width, height: this.height, rotation: this.rotation }, + box + ); + const scale = fitScale(shape.housing, this.angle, box); + const view = inkView(shape.screen, this.angle); + const size = strings.common.sizeInPixels(this.width, this.height); + const label = `${size} · ${this.rotation}°`; + const middle = `${box.width / 2} ${box.height / 2}`; + const placement = `translate(${middle}) rotate(${this.angle}) scale(${scale})`; + return html` + + + ${this.renderHousing(shape)} + ${this.renderSkeleton(view)} + + + `; + } +} + +const prefersReducedMotion = (): boolean => + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + +declare global { + interface HTMLElementTagNameMap { + "ods-display-preview": OdsDisplayPreview; + } +} diff --git a/frontend-src/src/ods-gallery.ts b/frontend-src/src/ods-gallery.ts index 2101fcc..9181068 100644 --- a/frontend-src/src/ods-gallery.ts +++ b/frontend-src/src/ods-gallery.ts @@ -3,6 +3,7 @@ import { customElement, property, state } from "lit/decorators.js"; import { dashboardFormData, dashboardFormLabel, + dashboardFormHelper, dashboardFormSchema, settingsFormFields, dashboardIsValid, @@ -354,6 +355,7 @@ export class OdsGallery extends LitElement { .data=${dashboardFormData(dashboard)} .schema=${dashboardFormSchema(settingsFormFields(dashboard))} .computeLabel=${dashboardFormLabel} + .computeHelper=${dashboardFormHelper(dashboard.display.rotation)} @value-changed=${this.settingsChanged} >
diff --git a/frontend-src/src/ods-inspector.ts b/frontend-src/src/ods-inspector.ts index 83c3ef1..c050ffb 100644 --- a/frontend-src/src/ods-inspector.ts +++ b/frontend-src/src/ods-inspector.ts @@ -557,6 +557,17 @@ export class OdsInspector extends LitElement { `; } + /** Padding and snapping, for a dashboard whose elements are placed by hand. */ + private renderWorkingArea(): TemplateResult { + return html` +
+ ${this.renderDisplayField(strings.fields.padding, "padding", 0, 1024)} + ${this.renderDisplayField(strings.fields.snapSize, "snapSize", 1, 256)} +
+

${strings.inspector.workingAreaHelp}

+ `; + } + private renderDashboardInspector(): TemplateResult { return html` ${this.renderHeader( @@ -568,11 +579,7 @@ export class OdsInspector extends LitElement { ${strings.inspector.appearance}
${this.renderDashboardBackground()} -
- ${this.renderDisplayField(strings.fields.padding, "padding", 0, 1024)} - ${this.renderDisplayField(strings.fields.snapSize, "snapSize", 1, 256)} -
-

${strings.inspector.workingAreaHelp}

+ ${this.isBasic ? nothing : this.renderWorkingArea()}
${this.renderMetrics()} @@ -1114,7 +1121,8 @@ export class OdsInspector extends LitElement { private renderItemSections(item: StudioItem): TemplateResult { if (item.kind === "widget") { return html` - ${this.renderLayoutSection(item)} ${this.renderWidgetSettings(item)} + ${this.isBasic ? nothing : this.renderLayoutSection(item)} + ${this.renderWidgetSettings(item)} `; } if (item.kind === "container") { @@ -1231,6 +1239,26 @@ export class OdsInspector extends LitElement { return this.renderDashboardInspector(); } + /** A basic dashboard has no tree: its widgets are told apart by their regions. */ + private get isBasic(): boolean { + return this.dashboard.mode === "basic"; + } + + private renderStructure(): TemplateResult | typeof nothing { + if (this.isBasic) return nothing; + return html` + + `; + } + protected render(): TemplateResult { if (this.collapsed) { return this.renderRail(); @@ -1245,15 +1273,7 @@ export class OdsInspector extends LitElement { aria-label=${strings.inspector.resize} @pointerdown=${this.startResize} >
- + ${this.renderStructure()}
${this.renderProperties(item)}
`; diff --git a/frontend-src/src/ods-layout-panel.ts b/frontend-src/src/ods-layout-panel.ts new file mode 100644 index 0000000..91cda59 --- /dev/null +++ b/frontend-src/src/ods-layout-panel.ts @@ -0,0 +1,165 @@ +import { css, html, LitElement, nothing, type TemplateResult } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { emit, type OdsEvent } from "./events"; +import { strings } from "./strings"; +import { baseStyles, chromeStyles } from "./studio-styles"; +import type { GridLayout, LayoutLimits } from "./types"; +import "./ods-property-field"; + +type GridKey = "columns" | "rows" | "gap"; + +const isGridKey = (key: string): key is GridKey => + key === "columns" || key === "rows" || key === "gap"; + +const PADDING_KEY = "padding"; +const MAX_PADDING = 1024; + +/** + * The left panel of the Layout tab: how many columns and rows the grid has, the gap + * between its cells, and the padding around the grid. + */ +@customElement("ods-layout-panel") +export class OdsLayoutPanel extends LitElement { + static styles = [ + baseStyles, + chromeStyles, + css` + :host { + display: contents; + } + .toolbox { + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + overflow: auto; + border-inline-end: 1px solid var(--studio-border); + background: var(--studio-surface); + } + .panel-title { + min-height: 58px; + display: flex; + align-items: center; + padding: 11px 12px; + } + .panel-title h2 { + margin: 1px 0 0; + font-size: 15px; + } + .section { + display: grid; + gap: var(--ha-space-2, 8px); + padding: var(--ha-space-1, 4px) var(--ha-space-3, 12px) + var(--ha-space-4, 16px); + } + .section h3 { + margin: 0; + color: var(--studio-muted); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + } + .fields { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--ha-space-2, 8px); + } + p { + margin: 0; + color: var(--studio-muted); + font-size: 12px; + line-height: 1.45; + } + .count { + color: var(--studio-text); + font-weight: 600; + } + `, + ]; + + @property({ attribute: false }) public layout!: GridLayout; + @property({ attribute: false }) public limits!: LayoutLimits; + /** The space kept free around the grid, a setting of the display. */ + @property({ type: Number }) public padding = 0; + + private onFieldChange(event: OdsEvent<"field-change">): void { + event.stopPropagation(); + const { key, value } = event.detail; + const number = Number(value); + if (!Number.isFinite(number)) return; + if (isGridKey(key)) { + emit(this, "grid-change", { [key]: number }); + } + if (key === PADDING_KEY) { + emit(this, "display-number-change", { key: PADDING_KEY, value: number }); + } + } + + private renderField( + key: GridKey | typeof PADDING_KEY, + value: number, + label: string, + min: number, + max: number + ): TemplateResult { + return html` + + `; + } + + private renderGrid(): TemplateResult { + const { minLines, maxLines, maxGap } = this.limits; + return html` +
+

${strings.basic.gridHeading}

+
+ ${this.renderField("columns", this.layout.columns, strings.basic.columns, minLines, maxLines)} + ${this.renderField("rows", this.layout.rows, strings.basic.rows, minLines, maxLines)} + ${this.renderField("gap", this.layout.gap, strings.basic.gap, 0, maxGap)} + ${this.renderField(PADDING_KEY, this.padding, strings.fields.padding, 0, MAX_PADDING)} +
+
+ `; + } + + private renderRegions(): TemplateResult { + return html` +
+

${strings.basic.regionsHeading}

+ + ${strings.basic.regionCount(this.layout.regions.length)} + +

${strings.basic.regionsHint}

+
+ `; + } + + protected render(): TemplateResult | typeof nothing { + if (!this.layout || !this.limits) return nothing; + return html` + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ods-layout-panel": OdsLayoutPanel; + } +} diff --git a/frontend-src/src/ods-library.ts b/frontend-src/src/ods-library.ts index f31beae..e78cb21 100644 --- a/frontend-src/src/ods-library.ts +++ b/frontend-src/src/ods-library.ts @@ -287,6 +287,8 @@ export class OdsLibrary extends LitElement { @property({ attribute: false }) public widgetErrors: WidgetLoadError[] = []; @property({ attribute: false }) public primitives: PrimitiveDefinition[] = []; @property({ type: Boolean }) public collapsed = false; + /** The basic editor offers widgets only: no containers and no primitives. */ + @property({ type: Boolean }) public widgetsOnly = false; @state() private searchText = ""; /** The sections the viewer folded; a search opens them all. */ @@ -575,6 +577,9 @@ export class OdsLibrary extends LitElement { primitives: PrimitiveEntry[], widgets: WidgetDefinition[] ): TemplateResult { + if (this.widgetsOnly) { + return this.renderWidgetSection(widgets); + } const searching = this.searchText !== ""; if ( searching && @@ -595,6 +600,12 @@ export class OdsLibrary extends LitElement { `; } + private get heading(): string { + return this.widgetsOnly + ? strings.basic.widgetsHeading + : strings.library.heading; + } + protected render(): TemplateResult { if (this.collapsed) { return html` @@ -622,7 +633,7 @@ export class OdsLibrary extends LitElement {
${strings.library.title} -

${strings.library.heading}

+

${this.heading}

+ `; + } + + private renderPaletteSummary(): TemplateResult { + const { palette } = this.dashboard.display; + return html` + + + ${PALETTE_COLORS[palette].map( + (color) => html` + + ` + )} + + ${PALETTE_LABELS[palette]} + + `; + } + + /** The display as it will be, which the arrows turn a quarter at a time. */ + private renderPreview(): TemplateResult { + const { display } = this.dashboard; + const text = strings.newDashboard; + return html` +
+
+ ${this.renderTurnButton(-1, text.turnLeft, "mdi:rotate-left")} + ${this.renderTurnButton(1, text.turnRight, "mdi:rotate-right")} + +
+
+ + ${strings.common.sizeInPixels(display.width, display.height)} · + ${display.rotation}° + + ${this.renderPaletteSummary()} +
+
+ `; + } + + /** Padding and snapping belong to the editor that places elements by hand. */ + private renderAdvanced(): TemplateResult | typeof nothing { + if (this.dashboard.mode === "basic") return nothing; + return this.renderForm(advancedFields()); } protected render(): TemplateResult { @@ -342,16 +530,10 @@ export class OdsNewDashboardDialog extends LitElement { @closed=${this.close} >
+ ${this.renderModes()} ${strings.newDashboard.startFrom} - ${this.renderSources()} ${this.renderSourceBody()} - + ${this.renderSources()} ${this.renderPickerAndName()} + ${this.renderPreview()} ${this.renderAdvanced()}
=> ({ + left: `${frame.x}px`, + top: `${frame.y}px`, + width: `${frame.width}px`, + height: `${frame.height}px`, +}); + +/** + * The regions of a basic dashboard, drawn over the picture of the display. On the Layout + * tab the cells of the grid take two clicks to join into a region; on the Widgets tab each + * region is a place a widget goes into, and a click selects what is in it. + */ +@customElement("ods-region-layer") +export class OdsRegionLayer extends LitElement { + static styles = [ + baseStyles, + css` + :host { + position: absolute; + inset: 0; + pointer-events: none; + } + button { + position: absolute; + box-sizing: border-box; + padding: 0; + pointer-events: auto; + cursor: pointer; + background: transparent; + color: var(--studio-text); + } + .cell { + border: calc(1px * var(--ui, 1)) dashed var(--studio-accent); + } + .cell.joined { + border-color: transparent; + } + .cell:hover, + .cell.in-rectangle { + background: var(--studio-accent-soft); + } + .cell.corner { + background: var(--studio-accent-soft); + border-style: solid; + } + .cell.in-rectangle.invalid { + background: color-mix( + in srgb, + var(--error-color, #db4437) 18%, + transparent + ); + border-color: var(--error-color, #db4437); + } + .outline { + position: absolute; + box-sizing: border-box; + border: calc(2px * var(--ui, 1)) solid var(--studio-accent); + pointer-events: none; + } + .outline { + display: grid; + place-content: center; + justify-items: center; + gap: calc(2px * var(--ui, 1)); + color: var(--studio-text); + } + .letter { + font-size: calc(26px * var(--ui, 1)); + font-weight: 700; + } + .size { + color: var(--studio-muted); + font: calc(10px * var(--ui, 1)) var(--code-font-family, monospace); + } + .outline.selected { + background: var(--studio-accent-soft); + box-shadow: 0 0 0 calc(2px * var(--ui, 1)) var(--studio-accent); + } + /* A region with a widget draws nothing of its own, so the picture is what will be + rendered; only an empty region is marked, and the selected one is outlined. */ + .slot { + border: 0; + } + .slot:hover { + box-shadow: inset 0 0 0 calc(1px * var(--ui, 1)) var(--studio-accent); + } + .slot.empty { + border: calc(1px * var(--ui, 1)) dashed var(--studio-accent); + background: color-mix(in srgb, var(--studio-surface) 70%, transparent); + } + .slot.empty:hover { + background: var(--studio-accent-soft); + box-shadow: none; + } + .slot.selected { + border: calc(2px * var(--ui, 1)) solid var(--studio-accent); + box-shadow: none; + } + .slot.empty.selected { + background: var(--studio-accent-soft); + } + .slot-label { + position: absolute; + top: 0; + left: 0; + max-width: 100%; + padding: calc(2px * var(--ui, 1)) calc(6px * var(--ui, 1)); + overflow: hidden; + background: var(--studio-accent); + color: var(--studio-on-accent, #fff); + font-size: calc(11px * var(--ui, 1)); + text-overflow: ellipsis; + white-space: nowrap; + } + .slot-empty { + display: grid; + gap: calc(4px * var(--ui, 1)); + place-items: center; + font-size: calc(12px * var(--ui, 1)); + } + .slot-empty ha-icon { + --mdc-icon-size: calc(28px * var(--ui, 1)); + color: var(--studio-accent); + } + .status { + position: absolute; + bottom: calc(8px * var(--ui, 1)); + left: 50%; + padding: calc(4px * var(--ui, 1)) calc(10px * var(--ui, 1)); + border-radius: 999px; + background: var(--studio-text); + color: var(--studio-surface); + font-size: calc(12px * var(--ui, 1)); + transform: translateX(-50%); + white-space: nowrap; + } + `, + ]; + + @property({ attribute: false }) public dashboard!: Dashboard; + @property({ attribute: false }) public frames?: LayoutFrames; + @property() public step: BasicStep = "layout"; + @property() public selectedRegionId = ""; + + /** The first corner of a region being drawn. */ + @state() private corner?: CellPosition; + /** The cell under the pointer; with a corner set, it is the second corner. */ + @state() private hover?: CellPosition; + + connectedCallback(): void { + super.connectedCallback(); + window.addEventListener("keydown", this.onKeyDown); + } + + disconnectedCallback(): void { + super.disconnectedCallback(); + window.removeEventListener("keydown", this.onKeyDown); + } + + protected willUpdate(changed: Map): void { + if (changed.has("step")) { + this.stopJoining(); + } + } + + private get layout(): GridLayout | undefined { + return this.dashboard.layout; + } + + private onKeyDown = (event: KeyboardEvent): void => { + if (event.key === "Escape" && this.corner) { + this.stopJoining(); + } + }; + + private hoverCell(cell: CellPosition): void { + this.hover = cell; + } + + private stopJoining(): void { + this.corner = undefined; + this.hover = undefined; + } + + private cellAt(index: number, layout: GridLayout): CellPosition { + return { + row: Math.floor(index / layout.columns), + column: index % layout.columns, + }; + } + + private isCorner(cell: CellPosition): boolean { + return this.corner?.row === cell.row && this.corner.column === cell.column; + } + + /** The cells from the first corner to the one under the pointer. */ + private get joining(): CellRectangle | undefined { + if (!this.corner || !this.hover) return undefined; + return rectangleBetween(this.corner, this.hover); + } + + private isInJoin(cell: CellPosition): boolean { + const join = this.joining; + if (!join) return false; + return ( + cell.row >= join.row && + cell.row < join.row + join.rowSpan && + cell.column >= join.column && + cell.column < join.column + join.columnSpan + ); + } + + /** A cell of a region of several cells shows no border, so the region reads as one. */ + private isInJoinedRegion(cell: CellPosition, layout: GridLayout): boolean { + const region = regionAt(layout, cell); + return ( + region !== undefined && (region.rowSpan > 1 || region.columnSpan > 1) + ); + } + + private joinIsValid(layout: GridLayout): boolean { + const join = this.joining; + return join !== undefined && canMerge(layout, join); + } + + private onCellClick(cell: CellPosition, layout: GridLayout): void { + const region = regionAt(layout, cell); + if (!this.corner) { + this.corner = cell; + if (region) emit(this, "region-select", { regionId: region.id }); + return; + } + const rectangle = rectangleBetween(this.corner, cell); + if (rectangle.rowSpan === 1 && rectangle.columnSpan === 1) { + this.stopJoining(); + return; + } + if (!canMerge(layout, rectangle)) return; + this.stopJoining(); + emit(this, "regions-merge", { rectangle }); + } + + /** Double-clicking a joined region cuts it back into cells. */ + private onCellDoubleClick(cell: CellPosition, layout: GridLayout): void { + const region = regionAt(layout, cell); + if (region && this.isInJoinedRegion(cell, layout)) { + this.stopJoining(); + emit(this, "region-split", { regionId: region.id }); + } + } + + private onSlotClick(regionId: string): void { + emit(this, "region-select", { regionId }); + } + + private renderCell( + frame: ItemFrame, + index: number, + layout: GridLayout + ): TemplateResult { + const cell = this.cellAt(index, layout); + const inJoin = this.isInJoin(cell); + const classes = [ + "cell", + this.isCorner(cell) ? "corner" : "", + inJoin ? "in-rectangle" : "", + this.isInJoinedRegion(cell, layout) ? "joined" : "", + inJoin && !this.joinIsValid(layout) ? "invalid" : "", + ].join(" "); + return html` + + `; + } + + /** A region of the layout: its letter, and how many cells wide and high it is. */ + private renderOutline( + frames: LayoutFrames, + region: GridRegion, + index: number + ): TemplateResult | typeof nothing { + // The frames come from the backend a moment after the layout changes. + const frame = frames.regions.find( + (candidate) => candidate.id === region.id + ); + if (!frame) return nothing; + const selected = region.id === this.selectedRegionId; + return html` +
+ ${regionLetter(index)} + + ${strings.basic.regionSize(region.columnSpan, region.rowSpan)} + +
+ `; + } + + private renderLayoutStep( + frames: LayoutFrames, + layout: GridLayout + ): TemplateResult { + return html` + ${layout.regions.map((region, index) => + this.renderOutline(frames, region, index) + )} + ${frames.cells.map((frame, index) => + this.renderCell(frame, index, layout) + )} + ${this.renderStatus(layout)} + `; + } + + /** What to do next while a region is being drawn. */ + private statusMessage(layout: GridLayout): string | undefined { + if (this.corner) { + const invalid = this.joining !== undefined && !this.joinIsValid(layout); + return invalid ? strings.basic.joinInvalid : strings.basic.joinStarted; + } + return undefined; + } + + private renderStatus(layout: GridLayout): TemplateResult | typeof nothing { + const message = this.statusMessage(layout); + if (!message) return nothing; + return html` +
${message}
+ `; + } + + /** An empty region says so; a filled one names its widget while it is selected. */ + private renderSlotContent( + regionId: string, + letter: string + ): TemplateResult | typeof nothing { + const widget = widgetInRegion(this.dashboard, regionId); + if (!widget) { + return html` + + ${letter} + ${strings.basic.emptyRegion} + + `; + } + if (regionId !== this.selectedRegionId) return nothing; + return html` + ${widget.name} + `; + } + + private renderSlot( + frame: ItemFrame, + regionId: string, + letter: string + ): TemplateResult { + const empty = !widgetInRegion(this.dashboard, regionId); + const selected = regionId === this.selectedRegionId; + const classes = [ + "slot", + empty ? "empty" : "", + selected ? "selected" : "", + ].join(" "); + return html` + + `; + } + + private renderWidgetsStep(frames: LayoutFrames): TemplateResult { + return html` + ${frames.regions.map((region, index) => + this.renderSlot(region, region.id, regionLetter(index)) + )} + `; + } + + protected render(): TemplateResult | typeof nothing { + const layout = this.layout; + const frames = this.frames; + if (!layout || !frames) return nothing; + return this.step === "layout" + ? this.renderLayoutStep(frames, layout) + : this.renderWidgetsStep(frames); + } +} + +declare global { + interface HTMLElementTagNameMap { + "ods-region-layer": OdsRegionLayer; + } +} diff --git a/frontend-src/src/rotation-motion.test.ts b/frontend-src/src/rotation-motion.test.ts new file mode 100644 index 0000000..ab098ae --- /dev/null +++ b/frontend-src/src/rotation-motion.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { shortestTurn, turnAngleAt } from "./rotation-motion"; + +describe("turnAngleAt", () => { + it("starts where it was and ends where it is going", () => { + expect(turnAngleAt(0, 90, 0)).toBe(0); + expect(turnAngleAt(0, 90, 1)).toBe(90); + expect(turnAngleAt(90, 0, 1)).toBe(0); + }); + + it("first leans back the other way", () => { + expect(turnAngleAt(0, 90, 0.1)).toBeLessThan(0); + expect(turnAngleAt(0, -90, 0.1)).toBeGreaterThan(0); + }); + + it("swings past the end and settles back", () => { + const beyond = turnAngleAt(0, 90, 0.78); + expect(beyond).toBeGreaterThan(90); + + expect(turnAngleAt(0, 90, 0.99)).toBeCloseTo(90, 0); + }); + + it("does not move when there is nowhere to go", () => { + expect(turnAngleAt(90, 90, 0.5)).toBe(90); + }); + + it("never jumps from one moment to the next", () => { + let previous = turnAngleAt(0, 90, 0); + for (let step = 1; step <= 100; step += 1) { + const angle = turnAngleAt(0, 90, step / 100); + expect(Math.abs(angle - previous)).toBeLessThan(12); + previous = angle; + } + }); +}); + +describe("shortestTurn", () => { + it("turns a quarter to either side", () => { + expect(shortestTurn(0, 90)).toBe(90); + expect(shortestTurn(0, 270)).toBe(-90); + }); + + it("keeps turning the same way past a full circle", () => { + expect(shortestTurn(270, 0)).toBe(90); + expect(shortestTurn(90, 0)).toBe(-90); + }); + + it("goes the same way for half a turn, whatever the angle", () => { + expect(shortestTurn(0, 180)).toBe(180); + expect(shortestTurn(90, 270)).toBe(180); + }); + + it("copes with an angle that has gone round many times", () => { + expect(shortestTurn(720 + 90, 180)).toBe(90); + expect(shortestTurn(-450, 0)).toBe(90); + }); +}); diff --git a/frontend-src/src/rotation-motion.ts b/frontend-src/src/rotation-motion.ts new file mode 100644 index 0000000..19ba4d8 --- /dev/null +++ b/frontend-src/src/rotation-motion.ts @@ -0,0 +1,54 @@ +/** + * How a display turns in the preview: it first leans a little the wrong way, swings round + * past where it ends up, and settles back. All in degrees and milliseconds. + */ +export const TURN_DURATION_MS = 860; +const LEAN_BACK_DEGREES = 6; +const OVERSHOOT_DEGREES = 9; + +/** Where each part of the motion ends, as a share of the whole. */ +const LEAN_ENDS = 0.13; +const SWING_ENDS = 0.76; + +const easeOutSine = (progress: number): number => + Math.sin((Math.PI / 2) * progress); + +const easeOutCubic = (progress: number): number => 1 - (1 - progress) ** 3; + +const clamp01 = (value: number): number => Math.min(1, Math.max(0, value)); + +/** The angle at `progress` (0 to 1) of a turn from `from` to `to`. */ +export const turnAngleAt = ( + from: number, + to: number, + progress: number +): number => { + const direction = Math.sign(to - from); + if (direction === 0) return to; + const time = clamp01(progress); + if (time >= 1) return to; + if (time < LEAN_ENDS) { + const lean = easeOutSine(time / LEAN_ENDS); + return from - direction * LEAN_BACK_DEGREES * lean; + } + if (time < SWING_ENDS) { + const swing = easeOutCubic((time - LEAN_ENDS) / (SWING_ENDS - LEAN_ENDS)); + const start = from - direction * LEAN_BACK_DEGREES; + const reach = + to - from + direction * (LEAN_BACK_DEGREES + OVERSHOOT_DEGREES); + return start + reach * swing; + } + const settle = easeOutCubic((time - SWING_ENDS) / (1 - SWING_ENDS)); + return to + direction * OVERSHOOT_DEGREES * (1 - settle); +}; + +/** + * The turn that takes a display from one of the four rotations to another by the short way, + * so going from 270° to 0° keeps turning forward. Half a turn goes the same way as a click on + * the right arrow. + */ +export const shortestTurn = (fromAngle: number, toRotation: number): number => { + const current = ((fromAngle % 360) + 360) % 360; + const raw = ((toRotation - current + 540) % 360) - 180; + return raw === -180 ? 180 : raw; +}; diff --git a/frontend-src/src/strings.de.ts b/frontend-src/src/strings.de.ts index faf2237..6836ba0 100644 --- a/frontend-src/src/strings.de.ts +++ b/frontend-src/src/strings.de.ts @@ -150,6 +150,9 @@ export const german: StringsOverride = { deviceDetails: "Größe und Farben des Dashboards", colors: "Farben", create: "Dashboard erstellen", + previewLabel: "Vorschau des Displays", + turnLeft: "Um 90° nach links drehen", + turnRight: "Um 90° nach rechts drehen", creating: "Wird erstellt…", }, @@ -370,6 +373,17 @@ export const german: StringsOverride = { background: "Hintergrund", advanced: "Erweiterte Display-Optionen", padding: "Äußerer Rand", + mediaAsDesigned: "Bild der Media Source wie im Editor", + mediaAsDesignedHelp: (rotation: number): string => + rotation === 0 + ? "Das Bild der Media Source ist in beiden Fällen gleich: Dieses Dashboard ist nicht gedreht." + : [ + "Das Bild der Media Source behält die Ausrichtung aus dem Editor,", + "passend für eine Vorschau oder eine Karte. Es ist nicht für das Display gedreht;", + `um es mit opendisplay.upload_image zu zeigen, setze rotation auf ${rotation}.`, + "Ausgeschaltet wird das Bild für das Display gedreht.", + "Was der Editor an ein Gerät sendet, ist immer für dieses gedreht.", + ].join(" "), snapSize: "Rasterschritt", innerPadding: "Innerer Rand", }, @@ -585,4 +599,44 @@ export const german: StringsOverride = { grayscale4: "Graustufen · 4 Stufen", grayscale16: "Graustufen · 16 Stufen", }, + + basic: { + modeTitle: "Editor", + basic: "Einfach", + basicHint: "Ein Raster aus Bereichen, in jedem ein Widget.", + advanced: "Erweitert", + advancedHint: "Jedes Element frei platzieren.", + badgeBasic: "Einfach", + badgeAdvanced: "Erweitert", + stepsLabel: "Schritte des Editors", + stepLayout: "Layout", + stepWidgets: "Widgets", + widgetsHeading: "Widgets", + layoutTitle: "Layout", + layoutHeading: "Bereiche", + gridHeading: "Raster", + columns: "Spalten", + rows: "Zeilen", + gap: "Abstand", + regionsHeading: "Bereiche", + regionsHint: + "Klicke eine Zelle und dann eine zweite, um die Zellen dazwischen zu einem Bereich zu verbinden. Klicke einen Bereich doppelt an, um ihn zu entfernen.", + joinStarted: "Klicke eine zweite Zelle, um den Bereich abzuschließen.", + joinInvalid: "Das würde einen Bereich zerschneiden.", + cancelJoin: "Abbrechen", + regionCount: (count: number): string => + `${count} ${count === 1 ? "Bereich" : "Bereiche"}`, + cell: (row: number, column: number): string => + `Zelle in Zeile ${row} und Spalte ${column}`, + regionSize: (columns: number, rows: number): string => + `Bereich ${columns}×${rows}`, + region: (name: string): string => `Bereich ${name}`, + emptyRegion: "Leerer Bereich", + emptyRegionHint: "Wähle ein Widget aus der Liste.", + noEmptyRegion: "Jeder Bereich enthält schon ein Widget.", + layoutStepHint: + "Zeichne zuerst die Bereiche und fülle sie dann im Tab Widgets.", + widgetsStepHint: + "Ziehe ein Widget in einen Bereich oder klicke es an, um den gewählten Bereich zu füllen.", + }, }; diff --git a/frontend-src/src/strings.pl.ts b/frontend-src/src/strings.pl.ts index 1ddef87..197191b 100644 --- a/frontend-src/src/strings.pl.ts +++ b/frontend-src/src/strings.pl.ts @@ -165,6 +165,9 @@ export const polish: StringsOverride = { deviceDetails: "Rozmiar i kolory dashboardu", colors: "Kolory", create: "Utwórz dashboard", + previewLabel: "Podgląd wyświetlacza", + turnLeft: "Obróć o 90° w lewo", + turnRight: "Obróć o 90° w prawo", creating: "Tworzenie…", }, @@ -383,6 +386,17 @@ export const polish: StringsOverride = { background: "Tło", advanced: "Zaawansowane ustawienia wyświetlacza", padding: "Margines zewnętrzny", + mediaAsDesigned: "Obraz Media Source tak jak w edytorze", + mediaAsDesignedHelp: (rotation: number): string => + rotation === 0 + ? "Obraz Media Source jest taki sam w obu przypadkach: ten dashboard nie jest obrócony." + : [ + "Obraz Media Source zachowuje orientację z edytora, co dobrze nadaje się", + "do podglądu albo karty. Nie jest obrócony dla wyświetlacza, więc aby pokazać go", + `przez opendisplay.upload_image, ustaw rotation na ${rotation}.`, + "Gdy wyłączone, obraz jest obrócony dla wyświetlacza.", + "To, co edytor wysyła na urządzenie, jest zawsze obrócone dla niego.", + ].join(" "), snapSize: "Krok przyciągania", innerPadding: "Margines wewnętrzny", }, @@ -595,4 +609,44 @@ export const polish: StringsOverride = { grayscale4: "Skala szarości · 4 poziomy", grayscale16: "Skala szarości · 16 poziomów", }, + + basic: { + modeTitle: "Edytor", + basic: "Podstawowy", + basicHint: "Siatka regionów, w każdym widżet.", + advanced: "Zaawansowany", + advancedHint: "Każdy element ustawiasz swobodnie.", + badgeBasic: "Podstawowy", + badgeAdvanced: "Zaawansowany", + stepsLabel: "Kroki edytora", + stepLayout: "Układ", + stepWidgets: "Widżety", + widgetsHeading: "Widżety", + layoutTitle: "Układ", + layoutHeading: "Regiony", + gridHeading: "Siatka", + columns: "Kolumny", + rows: "Wiersze", + gap: "Odstęp", + regionsHeading: "Regiony", + regionsHint: + "Kliknij jedną komórkę, potem drugą, aby połączyć komórki między nimi w region. Kliknij region dwukrotnie, aby go usunąć.", + joinStarted: "Kliknij drugą komórkę, aby zakończyć region.", + joinInvalid: "To przecięłoby region na pół.", + cancelJoin: "Anuluj", + regionCount: (count: number): string => + `${count} ${plural(count, "region", "regiony", "regionów")}`, + cell: (row: number, column: number): string => + `Komórka w wierszu ${row} i kolumnie ${column}`, + regionSize: (columns: number, rows: number): string => + `region ${columns}×${rows}`, + region: (name: string): string => `Region ${name}`, + emptyRegion: "Pusty region", + emptyRegionHint: "Wybierz widżet z listy.", + noEmptyRegion: "Każdy region ma już widżet.", + layoutStepHint: + "Najpierw narysuj regiony, potem wypełnij je na karcie Widżety.", + widgetsStepHint: + "Przeciągnij widżet do regionu albo kliknij go, aby wypełnić zaznaczony region.", + }, }; diff --git a/frontend-src/src/strings.ts b/frontend-src/src/strings.ts index b3b3339..a35f45c 100644 --- a/frontend-src/src/strings.ts +++ b/frontend-src/src/strings.ts @@ -154,6 +154,9 @@ export const strings = { deviceDetails: "Dashboard size and colors", colors: "Colors", create: "Create dashboard", + previewLabel: "Display preview", + turnLeft: "Turn 90° to the left", + turnRight: "Turn 90° to the right", creating: "Creating…", }, @@ -388,6 +391,17 @@ export const strings = { advanced: "Advanced display options", padding: "Outer padding", snapSize: "Snap size", + mediaAsDesigned: "Media Source picture as designed", + mediaAsDesignedHelp: (rotation: number): string => + rotation === 0 + ? "The picture of the Media Source is the same either way: this dashboard is not turned." + : [ + "The picture of the Media Source keeps the orientation you see in the designer,", + "which suits a preview or a card. It is not turned for the display, so to show", + `it with opendisplay.upload_image set rotation to ${rotation}.`, + "Left off, the picture is turned for the display.", + "What the editor sends to a device is always turned for it.", + ].join(" "), x: "X", y: "Y", innerPadding: "Inner padding", @@ -590,4 +604,44 @@ export const strings = { grayscale4: "Grayscale · 4 levels", grayscale16: "Grayscale · 16 levels", }, + + basic: { + modeTitle: "Editor", + basic: "Basic", + basicHint: "A grid of regions, a widget in each.", + advanced: "Advanced", + advancedHint: "Place every element freely.", + badgeBasic: "Basic", + badgeAdvanced: "Advanced", + stepsLabel: "Editor steps", + stepLayout: "Layout", + stepWidgets: "Widgets", + widgetsHeading: "Widgets", + layoutTitle: "Layout", + layoutHeading: "Regions", + gridHeading: "Grid", + columns: "Columns", + rows: "Rows", + gap: "Gap", + regionsHeading: "Regions", + regionsHint: + "Click one cell, then another, to join the cells between them into a region. Double-click a region to remove it.", + joinStarted: "Click a second cell to finish the region.", + joinInvalid: "That would cut a region in two.", + cancelJoin: "Cancel", + regionCount: (count: number): string => + `${count} ${count === 1 ? "region" : "regions"}`, + cell: (row: number, column: number): string => + `Cell in row ${row} and column ${column}`, + regionSize: (columns: number, rows: number): string => + `${columns}×${rows} region`, + region: (name: string): string => `Region ${name}`, + emptyRegion: "Empty region", + emptyRegionHint: "Pick a widget from the list.", + noEmptyRegion: "Every region already holds a widget.", + layoutStepHint: + "Draw the regions first, then fill them on the Widgets tab.", + widgetsStepHint: + "Drag a widget into a region, or click one to fill the selected region.", + }, } as const; diff --git a/frontend-src/src/studio-api.ts b/frontend-src/src/studio-api.ts index f6a7821..ed638cf 100644 --- a/frontend-src/src/studio-api.ts +++ b/frontend-src/src/studio-api.ts @@ -6,7 +6,9 @@ import type { FontInfo, HomeAssistant, IconCatalog, + GridLayout, ImportPlan, + LayoutFrames, ReloadWidgetsResponse, } from "./types"; @@ -18,6 +20,20 @@ export const bootstrap = (hass: HomeAssistant): Promise => language: hass.language, }); +/** Where the cells and regions of a layout are on a display, laid out by the backend. */ +export const layoutFrames = ( + hass: HomeAssistant, + display: { width: number; height: number; padding: number }, + layout: GridLayout +): Promise => + hass.callWS({ + type: "opendisplay_studio/layout_frames", + width: display.width, + height: display.height, + padding: display.padding, + layout, + }); + /** Loads the widget packages again, without restarting Home Assistant. */ export const reloadWidgets = ( hass: HomeAssistant diff --git a/frontend-src/src/studio-styles.ts b/frontend-src/src/studio-styles.ts index 4ee14a4..977f2d0 100644 --- a/frontend-src/src/studio-styles.ts +++ b/frontend-src/src/studio-styles.ts @@ -82,7 +82,6 @@ export const tileStyles = css` .dashboard-card, .dashboard-add-card { min-width: 0; - min-height: 236px; padding: 0; border: 1px solid var(--studio-border); border-radius: 13px; @@ -90,6 +89,10 @@ export const tileStyles = css` background: var(--studio-surface); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); } + /* A card is as tall as its picture and its two lines; the add card matches it. */ + .dashboard-add-card { + min-height: 200px; + } .dashboard-card:hover, .dashboard-card:focus-within, .dashboard-add-card:hover, diff --git a/frontend-src/src/test-support.ts b/frontend-src/src/test-support.ts index 93c8abd..c624070 100644 --- a/frontend-src/src/test-support.ts +++ b/frontend-src/src/test-support.ts @@ -72,6 +72,7 @@ export const dashboardWith = ( id: "d1", schemaVersion: 1, name: "Test", + mode: "advanced", status: "draft", language: "en", display: { @@ -84,6 +85,7 @@ export const dashboardWith = ( snapSize: 5, rotation: 0, deviceId: null, + mediaAsDesigned: false, ...display, }, items, diff --git a/frontend-src/src/types.ts b/frontend-src/src/types.ts index 92bff97..231a253 100644 --- a/frontend-src/src/types.ts +++ b/frontend-src/src/types.ts @@ -51,6 +51,8 @@ export interface WidgetItem extends ItemState { }; frame: ItemFrame; layout: { padding: number }; + /** The region of a basic dashboard's layout the widget fills; its frame follows it. */ + region?: string; } /** What every primitive but the debug grid can be: turned clockwise about its middle. */ @@ -296,10 +298,47 @@ export type StudioItem = LeafItem | ContainerItem; /** How far, clockwise, the picture is turned before a display gets it. */ export type Rotation = 0 | 90 | 180 | 270; +/** How a dashboard is edited: by regions of a grid (basic) or by free placement (advanced). */ +export type DashboardMode = "basic" | "advanced"; + +/** A rectangle of cells of the grid; every cell of the grid belongs to exactly one. */ +export interface GridRegion { + id: string; + row: number; + column: number; + rowSpan: number; + columnSpan: number; +} + +export interface GridLayout { + columns: number; + rows: number; + gap: number; + regions: GridRegion[]; +} + +/** Where the cells and the regions of a layout are on the display, as the backend lays them. */ +export interface LayoutFrames { + cells: ItemFrame[]; + regions: Array; +} + +/** What the backend accepts of a grid; the panel never declares these itself. */ +export interface LayoutLimits { + minLines: number; + maxLines: number; + defaultGap: number; + maxGap: number; +} + export interface Dashboard { id: string; schemaVersion: 1; name: string; + /** Chosen when the dashboard is created and never changed afterwards. */ + mode: DashboardMode; + /** The grid of a basic dashboard; an advanced one has none. */ + layout?: GridLayout; status: DashboardStatus; language: string; display: { @@ -313,6 +352,11 @@ export interface Dashboard { rotation: Rotation; /** The OpenDisplay device the dashboard is made for, which it can be sent to. */ deviceId: string | null; + /** + * The Media Source gives the picture as it is designed, not turned for the display. + * What is sent to a device from the editor is turned for it either way. + */ + mediaAsDesigned: boolean; }; items: StudioItem[]; createdAt: string; @@ -547,6 +591,7 @@ export interface BootstrapResponse { widgetErrors: WidgetLoadError[]; primitives: PrimitiveDefinition[]; fonts: FontInfo[]; + layoutLimits: LayoutLimits; } export interface ReloadWidgetsResponse { diff --git a/frontend-src/tsconfig.json b/frontend-src/tsconfig.json index a032344..e41d1ea 100644 --- a/frontend-src/tsconfig.json +++ b/frontend-src/tsconfig.json @@ -40,6 +40,15 @@ "src/ods-property-field.ts", "src/ods-structure.ts", "src/ods-zoom-bar.ts", + "src/ods-basic-steps.ts", + "src/ods-display-preview.ts", + "src/display-shape.ts", + "src/rotation-motion.ts", + "src/ods-layout-panel.ts", + "src/ods-region-layer.ts", + "src/basic-ops.ts", + "src/grid-layout.ts", + "src/dev-layout.ts", "src/types.ts", "src/palettes.ts", "src/ods-popover.ts", diff --git a/pyproject.toml b/pyproject.toml index 4f90ed2..5704343 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "opendisplay-studio-integration" -version = "0.2.0" +version = "0.3.0" requires-python = ">=3.14.2" [tool.pytest.ini_options] diff --git a/tests/test_basic_layout.py b/tests/test_basic_layout.py new file mode 100644 index 0000000..edfdce2 --- /dev/null +++ b/tests/test_basic_layout.py @@ -0,0 +1,357 @@ +"""A basic dashboard is a grid cut into regions, with a widget in each region.""" + +from __future__ import annotations + +from copy import deepcopy +from typing import TYPE_CHECKING, Any + +import pytest + +from custom_components.opendisplay_studio.basic_layout import ( + grid_cells, + region_frame, + validate_layout, + working_area, +) +from custom_components.opendisplay_studio.compiler import async_compile_dashboard +from custom_components.opendisplay_studio.dashboards import ( + DashboardValidationError, + validate_dashboard, +) +from custom_components.opendisplay_studio.widgets import DEFAULT_REGISTRY + +from .test_items import dashboard, text +from .test_widgets import widget_item + +if TYPE_CHECKING: + from homeassistant.core import HomeAssistant + + +def region( + region_id: str, row: int, column: int, row_span: int = 1, column_span: int = 1 +) -> dict[str, Any]: + return { + "id": region_id, + "row": row, + "column": column, + "rowSpan": row_span, + "columnSpan": column_span, + } + + +def two_by_two(**overrides: Any) -> dict[str, Any]: + return { + "columns": 2, + "rows": 2, + "gap": 8, + "regions": [ + region("a", 0, 0), + region("b", 0, 1), + region("c", 1, 0), + region("d", 1, 1), + ], + **overrides, + } + + +def in_region(region_id: str, **overrides: Any) -> dict[str, Any]: + item = widget_item("sensor-card", item_id=f"w-{region_id}") + return {**item, "region": region_id, **overrides} + + +def basic(items: list[dict[str, Any]], layout: dict[str, Any] | None = None) -> dict: + return { + **dashboard(items), + "mode": "basic", + "layout": layout if layout is not None else two_by_two(), + } + + +def validated(value: dict[str, Any]) -> dict[str, Any]: + return validate_dashboard(value, DEFAULT_REGISTRY) + + +def test_the_cells_of_a_grid_fill_the_working_area_with_the_gap_between_them() -> None: + area = working_area(800, 480, 20) + + cells = grid_cells(area, 2, 2, 8) + + assert [(c.x, c.y, c.width, c.height) for c in cells] == [ + (20, 20, 376, 216), + (404, 20, 376, 216), + (20, 244, 376, 216), + (404, 244, 376, 216), + ] + + +def test_a_region_spans_from_its_first_cell_to_its_last() -> None: + cells = grid_cells(working_area(800, 480, 0), 2, 2, 8) + + assert region_frame(cells, 2, region("wide", 0, 0, column_span=2)) == { + "x": 0, + "y": 0, + "width": 800, + "height": 236, + } + assert region_frame(cells, 2, region("tall", 0, 1, row_span=2)) == { + "x": 404, + "y": 0, + "width": 396, + "height": 480, + } + + +def test_a_valid_layout_is_kept_as_it_is() -> None: + assert validate_layout(two_by_two()) == two_by_two() + + +def test_a_layout_without_a_gap_gets_the_default_one() -> None: + layout = two_by_two() + del layout["gap"] + + assert validate_layout(layout)["gap"] == 8 + + +@pytest.mark.parametrize( + "layout", + [ + two_by_two(columns=0), + two_by_two(rows=13), + two_by_two(gap=-1), + two_by_two(regions=[]), + two_by_two( + regions=[ + region("a", 0, 0), + region("a", 0, 1), + region("c", 1, 0), + region("d", 1, 1), + ] + ), + # A cell with no region. + two_by_two(regions=[region("a", 0, 0), region("b", 0, 1), region("c", 1, 0)]), + # Two regions on one cell. + two_by_two( + regions=[ + region("a", 0, 0, 2, 2), + region("b", 0, 1), + region("c", 1, 0), + region("d", 1, 1), + ] + ), + # Beyond the grid. + two_by_two( + regions=[region("a", 0, 0, 1, 3), region("c", 1, 0), region("d", 1, 1)] + ), + "a grid", + ], + ids=[ + "no columns", + "too many rows", + "negative gap", + "no regions", + "same id", + "a cell left out", + "overlap", + "beyond the grid", + "not an object", + ], +) +def test_a_layout_that_is_not_a_tiling_of_the_grid_is_refused(layout: Any) -> None: + with pytest.raises(DashboardValidationError): + validate_layout(layout) + + +def test_a_basic_dashboard_puts_each_widget_in_the_frame_of_its_region() -> None: + sent = [in_region("a"), in_region("d")] + for item in sent: + item["frame"] = {"x": 3, "y": 3, "width": 10, "height": 10} + + result = validated(basic(sent)) + + frames = {item["region"]: item["frame"] for item in result["items"]} + assert frames["a"] == {"x": 0, "y": 0, "width": 396, "height": 236} + assert frames["d"] == {"x": 404, "y": 244, "width": 396, "height": 236} + assert result["mode"] == "basic" + assert result["layout"] == two_by_two() + + +def test_a_basic_dashboard_follows_its_padding() -> None: + value = basic([in_region("a")]) + value["display"]["padding"] = 20 + + result = validated(value) + + assert result["items"][0]["frame"]["x"] == 20 + + +def test_a_region_may_be_empty() -> None: + assert validated(basic([in_region("b")]))["items"][0]["region"] == "b" + assert validated(basic([]))["items"] == [] + + +@pytest.mark.parametrize( + "items", + [ + [{**in_region("a"), "region": "nowhere"}], + [{k: v for k, v in in_region("a").items() if k != "region"}], + [in_region("a"), {**in_region("a"), "id": "second"}], + [text()], + ], + ids=["unknown region", "no region", "two widgets in one region", "not a widget"], +) +def test_a_basic_dashboard_holds_one_widget_in_each_region_and_nothing_else( + items: list[dict[str, Any]], +) -> None: + with pytest.raises(DashboardValidationError): + validated(basic(items)) + + +def test_a_basic_dashboard_needs_a_layout() -> None: + value = basic([]) + del value["layout"] + + with pytest.raises(DashboardValidationError): + validated(value) + + +def test_a_dashboard_without_a_mode_is_an_advanced_one_and_stays_as_it_was() -> None: + stored = dashboard([text()]) + assert "mode" not in stored + + result = validated(stored) + + assert result["mode"] == "advanced" + assert "layout" not in result + assert result["items"] == validated({**stored, "mode": "advanced"})["items"] + + +def test_an_advanced_dashboard_refuses_what_belongs_to_a_basic_one() -> None: + with pytest.raises(DashboardValidationError): + validated({**dashboard([]), "layout": two_by_two()}) + with pytest.raises(DashboardValidationError): + validated({**dashboard([in_region("a")])}) + + +def test_an_unknown_mode_is_refused() -> None: + with pytest.raises(DashboardValidationError): + validated({**dashboard([]), "mode": "simple"}) + + +def test_the_mode_survives_a_second_validation() -> None: + first = validated(basic([in_region("a")])) + + assert validated(deepcopy(first)) == first + + +async def test_a_basic_dashboard_is_drawn_inside_the_frames_of_its_regions( + hass: HomeAssistant, +) -> None: + result = validated(basic([in_region("a"), in_region("d")])) + + compiled = await async_compile_dashboard(hass, result, DEFAULT_REGISTRY) + + assert compiled.item_bounds["w-a"] == {"x": 0, "y": 0, "width": 396, "height": 236} + assert compiled.item_bounds["w-d"] == { + "x": 404, + "y": 244, + "width": 396, + "height": 236, + } + + +async def test_the_panel_asks_the_backend_for_the_cells_and_regions_of_a_layout( + call: Any, +) -> None: + layout = { + "columns": 2, + "rows": 2, + "gap": 8, + "regions": [ + {"id": "top", "row": 0, "column": 0, "rowSpan": 1, "columnSpan": 2}, + {"id": "left", "row": 1, "column": 0, "rowSpan": 1, "columnSpan": 1}, + {"id": "right", "row": 1, "column": 1, "rowSpan": 1, "columnSpan": 1}, + ], + } + + response = await call( + "layout_frames", width=800, height=480, padding=20, layout=layout + ) + + assert response["success"] + result = response["result"] + assert [(cell["x"], cell["y"]) for cell in result["cells"]] == [ + (20, 20), + (404, 20), + (20, 244), + (404, 244), + ] + assert result["regions"][0] == { + "id": "top", + "x": 20, + "y": 20, + "width": 760, + "height": 216, + } + + +async def test_a_layout_that_leaves_a_cell_empty_is_refused(call: Any) -> None: + layout = { + "columns": 2, + "rows": 1, + "gap": 8, + "regions": [{"id": "a", "row": 0, "column": 0, "rowSpan": 1, "columnSpan": 1}], + } + + response = await call( + "layout_frames", width=800, height=480, padding=20, layout=layout + ) + + assert response["error"]["code"] == "invalid_layout" + + +def test_a_layout_may_mix_regions_of_any_size() -> None: + """A tall region, a square one and single cells make a valid layout.""" + singles = [ + {"id": f"s{index}", "row": index, "column": 3, "rowSpan": 1, "columnSpan": 1} + for index in range(4) + ] + leftover = [ + { + "id": f"c{row}{column}", + "row": row, + "column": column, + "rowSpan": 1, + "columnSpan": 1, + } + for row in (2, 3) + for column in (1, 2) + ] + layout = { + "columns": 4, + "rows": 4, + "gap": 8, + "regions": [ + {"id": "tall", "row": 0, "column": 0, "rowSpan": 4, "columnSpan": 1}, + {"id": "square", "row": 0, "column": 1, "rowSpan": 2, "columnSpan": 2}, + *leftover, + *singles, + ], + } + + validated = validate_layout(layout) + + assert len(validated["regions"]) == 10 + assert validated["regions"][0]["rowSpan"] == 4 + + +def test_a_region_as_wide_as_the_grid_is_valid() -> None: + layout = { + "columns": 4, + "rows": 1, + "gap": 8, + "regions": [ + {"id": "all", "row": 0, "column": 0, "rowSpan": 1, "columnSpan": 4} + ], + } + + assert validate_layout(layout)["regions"][0]["columnSpan"] == 4 diff --git a/tests/test_dial_bands.py b/tests/test_dial_bands.py new file mode 100644 index 0000000..2cad2f7 --- /dev/null +++ b/tests/test_dial_bands.py @@ -0,0 +1,55 @@ +"""A band of a dial ends along the radius, as the tick line of its value does.""" + +from __future__ import annotations + +from math import atan2, degrees + +import pytest +from odl_renderer import generate_image + +from custom_components.opendisplay_studio.sdk.dial import Dial, band + +CENTER = 100 +RADIUS = 80 +THICKNESS = 8 +TOLERANCE = 1.0 # degrees: a pixel at the inner edge is about a degree wide +RED = (255, 0, 0) + + +async def angles_drawn(first: float, last: float) -> tuple[float, float]: + """Return the first and last angle, from the center, that a band leaves ink at.""" + dial = Dial(CENTER, CENTER, RADIUS, 0, 360, 0, 360) + elements = band(dial, first, last, thickness=THICKNESS, color="red") + image = await generate_image( + 2 * CENTER, 2 * CENTER, elements, background="white", accent_color="red" + ) + rgb = image.convert("RGB") + seen = [ + # Measured from the start, so a band that crosses 0 degrees is not split in two. + (degrees(atan2(y - CENTER, x - CENTER)) - first) % 360 + for y in range(rgb.height) + for x in range(rgb.width) + if rgb.getpixel((x, y)) == RED + ] + inside = [angle for angle in seen if angle <= (last - first) + 5] + return first + min(inside), first + max(inside) + + +@pytest.mark.parametrize( + ("first", "last"), + [ + (200, 250), # across the top left, a slanted cut + (170, 215), + (95, 175), # the lower left + (20, 60), + (370, 390), # near three o'clock, where the radius is nearly level + (255, 285), # around the top, where the radius is nearly upright + ], +) +async def test_the_ends_of_a_band_lie_along_the_radius( + first: float, last: float +) -> None: + start, end = await angles_drawn(first, last) + + assert start == pytest.approx(first, abs=TOLERANCE) + assert end == pytest.approx(last, abs=TOLERANCE) diff --git a/tests/test_media_rotation.py b/tests/test_media_rotation.py new file mode 100644 index 0000000..44ad412 --- /dev/null +++ b/tests/test_media_rotation.py @@ -0,0 +1,151 @@ +"""The Media Source picture is turned for the display, or kept as designed.""" + +from __future__ import annotations + +from io import BytesIO +from types import SimpleNamespace +from typing import TYPE_CHECKING, Any, cast + +import pytest +from homeassistant.components.media_source import MediaSourceItem +from homeassistant.core import ServiceCall +from PIL import Image + +from custom_components.opendisplay_studio.cache import RenderCache +from custom_components.opendisplay_studio.const import DOMAIN +from custom_components.opendisplay_studio.dashboards import validate_dashboard +from custom_components.opendisplay_studio.media_source import ( + OpenDisplayStudioMediaSource, +) +from custom_components.opendisplay_studio.rendering import OdlRenderService +from custom_components.opendisplay_studio.validation import DashboardValidationError +from custom_components.opendisplay_studio.widgets import DEFAULT_REGISTRY + +from .test_items import dashboard as dashboard_with +from .test_rotation import SQUARE, black_corner + +if TYPE_CHECKING: + from collections.abc import Callable, Coroutine + + from homeassistant.core import HomeAssistant + + type Call = Callable[..., Coroutine[Any, Any, dict[str, Any]]] + + +def design(**display: Any) -> dict[str, Any]: + """A portrait 480x800 canvas, turned a quarter, with a black square top-left.""" + value = dashboard_with([SQUARE]) + value["display"] = { + **value["display"], + "width": 480, + "height": 800, + "rotation": 90, + **display, + } + value["status"] = "ready" + value["id"] = "hall" + return validate_dashboard(value, DEFAULT_REGISTRY) + + +def use_media_source(hass: HomeAssistant, dashboard: dict[str, Any]) -> RenderCache: + cache = RenderCache(ttl_seconds=60, max_items=8) + store = SimpleNamespace(get=lambda _identifier: dashboard) + hass.data[DOMAIN] = SimpleNamespace( + widgets=DEFAULT_REGISTRY, + renderer=OdlRenderService(cast("Any", None), concurrency=1), + cache=cache, + dashboards=store, + ) + return cache + + +async def media_picture(hass: HomeAssistant, dashboard: dict[str, Any]) -> bytes: + cache = use_media_source(hass, dashboard) + source = OpenDisplayStudioMediaSource(hass) + + media = await source.async_resolve_media( + MediaSourceItem(hass, DOMAIN, dashboard["id"], None) + ) + + token = media.url.rsplit("/", 1)[-1].removesuffix(".png") + png = cache.get(token) + assert png is not None + return png + + +class TestTheSetting: + def test_a_dashboard_is_turned_for_its_display_unless_told_otherwise(self) -> None: + assert design()["display"]["mediaAsDesigned"] is False + + def test_a_dashboard_can_ask_for_the_picture_as_designed(self) -> None: + assert design(mediaAsDesigned=True)["display"]["mediaAsDesigned"] is True + + def test_a_dashboard_stored_without_the_setting_is_turned(self) -> None: + value = dashboard_with([SQUARE]) + + assert "mediaAsDesigned" not in value["display"] + validated = validate_dashboard(value, DEFAULT_REGISTRY) + assert validated["display"]["mediaAsDesigned"] is False + + @pytest.mark.parametrize("value", ["yes", 1, None]) + def test_only_a_boolean_is_accepted(self, value: Any) -> None: + with pytest.raises(DashboardValidationError, match="mediaAsDesigned"): + design(mediaAsDesigned=value) + + +class TestTheMediaSource: + async def test_the_picture_is_turned_for_the_display_by_default( + self, hass: HomeAssistant + ) -> None: + png = await media_picture(hass, design()) + + assert black_corner(png) == ((800, 480), "top-right") + + async def test_the_picture_keeps_the_orientation_of_the_designer_when_asked( + self, hass: HomeAssistant + ) -> None: + png = await media_picture(hass, design(mediaAsDesigned=True)) + + assert black_corner(png) == ((480, 800), "top-left") + + @pytest.mark.parametrize("rotation", [0, 90, 180, 270]) + async def test_as_designed_is_the_same_picture_whatever_the_rotation( + self, hass: HomeAssistant, rotation: int + ) -> None: + png = await media_picture(hass, design(mediaAsDesigned=True, rotation=rotation)) + + assert Image.open(BytesIO(png)).size == (480, 800) + assert black_corner(png)[1] == "top-left" + + +class TestSendingFromTheDesigner: + """A picture sent to a device is turned for it, whatever the Media Source gives.""" + + @pytest.mark.parametrize( + "setting", [{"mediaAsDesigned": False}, {"mediaAsDesigned": True}] + ) + async def test_the_device_gets_the_turned_picture_and_no_further_turn( + self, + hass: HomeAssistant, + call: Call, + studio: Any, + setting: dict[str, bool], + ) -> None: + calls: list[ServiceCall] = [] + + async def upload(service_call: ServiceCall) -> None: + calls.append(service_call) + + hass.services.async_register("opendisplay", "upload_image", upload) + dashboard = design(deviceId="device-1", **setting) + + response = await call("send_to_device", dashboard=dashboard) + + assert response["success"] + [sent] = calls + assert sent.data["rotation"] == 0 + identifier = sent.data["image"]["media_content_id"] + token = identifier.rsplit("sent-", 1)[-1] + png = studio.cache.get(token) + assert png is not None + assert black_corner(png) == ((800, 480), "top-right") diff --git a/tests/test_output_matches_preview.py b/tests/test_output_matches_preview.py new file mode 100644 index 0000000..8016f0e --- /dev/null +++ b/tests/test_output_matches_preview.py @@ -0,0 +1,169 @@ +"""What a display receives is what the editor shows: the same pixels, only turned.""" + +from __future__ import annotations + +from io import BytesIO +from types import SimpleNamespace +from typing import TYPE_CHECKING, Any + +import pytest +from PIL import Image, ImageChops + +from custom_components.opendisplay_studio.const import DOMAIN +from custom_components.opendisplay_studio.dashboards import validate_dashboard +from custom_components.opendisplay_studio.delivery import ( + async_render_dashboard, + preview_margin, +) +from custom_components.opendisplay_studio.rendering import OdlRenderService +from custom_components.opendisplay_studio.widgets import DEFAULT_REGISTRY + +from .test_items import dashboard as dashboard_with + +if TYPE_CHECKING: + from homeassistant.core import HomeAssistant + +RENDERER = OdlRenderService(None, concurrency=1) # type: ignore[arg-type] +WIDTH = 360 +HEIGHT = 184 + + +def text_item(**fields: Any) -> dict[str, Any]: + primitive = { + "type": "text", + "value": "It should work", + "x": 100, + "y": 90, + "size": 23, + "color": "black", + "anchor": "lm", + "font": "ppb.ttf", + **fields, + } + return { + "id": "label", + "kind": "primitive", + "name": "text_1", + "locked": False, + "hidden": False, + "primitive": primitive, + } + + +def rectangle_item(**fields: Any) -> dict[str, Any]: + primitive = { + "type": "rectangle", + "x_start": 0, + "y_start": 0, + "x_end": 59, + "y_end": 183, + "fill": "red", + "outline": "red", + "width": 2, + **fields, + } + return { + "id": "band", + "kind": "primitive", + "name": "rectangle_1", + "locked": False, + "hidden": False, + "primitive": primitive, + } + + +def dashboard(items: list[dict[str, Any]], rotation: int = 0) -> dict[str, Any]: + value = dashboard_with(items) + value["display"] = { + **value["display"], + "width": WIDTH, + "height": HEIGHT, + "palette": "bwry", + "rotation": rotation, + } + return validate_dashboard(value, DEFAULT_REGISTRY) + + +def picture(png: bytes) -> Image.Image: + return Image.open(BytesIO(png)).convert("RGB") + + +async def shown_in_the_editor( + hass: HomeAssistant, value: dict[str, Any] +) -> Image.Image: + """The display as the editor draws it: the picture with its margin cut off.""" + rendered = await async_render_dashboard( + hass, value, for_device=False, with_margin=True + ) + margin = preview_margin(value["display"]) + with_margin = picture(rendered.picture.png) + return with_margin.crop((margin, margin, margin + WIDTH, margin + HEIGHT)) + + +async def received_by_the_display( + hass: HomeAssistant, value: dict[str, Any] +) -> Image.Image: + """What the Media Source gives, turned back so it can be set beside the editor's.""" + rendered = await async_render_dashboard(hass, value, for_device=True) + delivered = picture(rendered.picture.png) + # The picture was turned clockwise by the dashboard's rotation. + return delivered.rotate(value["display"]["rotation"], expand=True) + + +def same_picture(left: Image.Image, right: Image.Image) -> bool: + """Whether two pictures of one size are the same pixel for pixel.""" + assert left.size == right.size + return ImageChops.difference(left, right).getbbox() is None + + +def agenda_item() -> dict[str, Any]: + return { + "id": "agenda", + "kind": "widget", + "name": "agenda_1", + "locked": False, + "hidden": False, + "widget": { + "type": "agenda", + "version": "1.0.0", + "sources": {"calendars": [{"id": "calendar.family"}]}, + "options": {"maxEvents": 5, "days": 14, "showFrame": False}, + }, + "frame": {"x": 60, "y": 0, "width": 300, "height": 184}, + "layout": {"padding": 0}, + } + + +CASES = { + "a text that hangs out to the left, turned upright": [ + rectangle_item(), + text_item(x=-50, y=92, rotation=-90, color="white"), + ], + "a text that hangs out at the top": [text_item(x=100, y=-8, anchor="lm")], + "a text that hangs out to the right": [text_item(x=300, y=90)], + "a text that hangs out at the bottom": [text_item(x=20, y=190)], + "a turned text partly outside": [text_item(x=-30, y=40, rotation=45)], + "a rectangle that reaches beyond the display": [ + rectangle_item(x_start=-40, y_start=-20, x_end=80, y_end=210) + ], + "a text well inside": [text_item()], + "a band, a text turned upright and a widget": [ + rectangle_item(), + text_item(x=-50, y=92, size=23, rotation=-90, color="white", align="center"), + agenda_item(), + ], +} + + +@pytest.mark.parametrize("rotation", [0, 90, 180, 270]) +@pytest.mark.parametrize("name", list(CASES)) +async def test_the_display_receives_what_the_editor_shows( + hass: HomeAssistant, name: str, rotation: int +) -> None: + hass.data[DOMAIN] = SimpleNamespace(widgets=DEFAULT_REGISTRY, renderer=RENDERER) + value = dashboard(CASES[name], rotation) + + editor = await shown_in_the_editor(hass, value) + display = await received_by_the_display(hass, value) + + assert same_picture(editor, display) diff --git a/tests/test_release_version.py b/tests/test_release_version.py index 4d16bfc..2cf6595 100644 --- a/tests/test_release_version.py +++ b/tests/test_release_version.py @@ -23,7 +23,7 @@ def test_release_rejects_mismatch(tmp_path) -> None: def test_panel_build_version_matches_release() -> None: - assert INTEGRATION_VERSION == "0.2.0" + assert INTEGRATION_VERSION == "0.3.0" def test_panel_does_not_bundle_home_assistant_component_implementations() -> None: diff --git a/tests/widgets/golden/weather/daily-default.png b/tests/widgets/golden/weather/daily-default.png index 9029227..5d9dc5e 100644 Binary files a/tests/widgets/golden/weather/daily-default.png and b/tests/widgets/golden/weather/daily-default.png differ diff --git a/tests/widgets/golden/weather/daily-large.png b/tests/widgets/golden/weather/daily-large.png index 356ac9e..33b8aa7 100644 Binary files a/tests/widgets/golden/weather/daily-large.png and b/tests/widgets/golden/weather/daily-large.png differ diff --git a/tests/widgets/golden/weather/daily-min.png b/tests/widgets/golden/weather/daily-min.png index 1978bbd..d748db0 100644 Binary files a/tests/widgets/golden/weather/daily-min.png and b/tests/widgets/golden/weather/daily-min.png differ diff --git a/tests/widgets/golden/weather/hourly-details-default.png b/tests/widgets/golden/weather/hourly-details-default.png index 10516cf..6810780 100644 Binary files a/tests/widgets/golden/weather/hourly-details-default.png and b/tests/widgets/golden/weather/hourly-details-default.png differ diff --git a/tests/widgets/golden/weather/hourly-details-large.png b/tests/widgets/golden/weather/hourly-details-large.png index c8146b2..4680a08 100644 Binary files a/tests/widgets/golden/weather/hourly-details-large.png and b/tests/widgets/golden/weather/hourly-details-large.png differ diff --git a/tests/widgets/golden/weather/hourly-details-min.png b/tests/widgets/golden/weather/hourly-details-min.png index 66f30da..89cdd2c 100644 Binary files a/tests/widgets/golden/weather/hourly-details-min.png and b/tests/widgets/golden/weather/hourly-details-min.png differ