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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ whole screen, invokes Home Assistant-aware providers, renders Liquid fragments,
and composes a single TRMNL document. The Renderer App contract remains
`HTML + width + height -> PNG` and contains no project or HA concepts.

Theme, font family, and text scale are project-level display preferences. The
composer maps them to TRMNL Framework screen classes before sending the same
HTML to preview and Media Source. Widgets do not own or duplicate these
settings; they react to the framework variables and their physical region
container.

Backend panel registration follows the current Core Dynalite pattern: local
static files, WebSocket commands, and an admin-only custom panel. Studio is not
a standalone frontend, so its component model follows the Matter and ZHA
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@

## Unreleased

## 0.5.1

- Replace the integration-owned Liquid subset with `trmnl-liquid-py` 0.1.0,
matching the supported non-I18n TRMNL Liquid 0.8.2 rendering surface.
- Validate every bundled widget template against the shared TRMNL engine and
cover lax missing-data and inline-template behavior with regression tests.
- Add project-level light and dark TRMNL themes.
- Add project-level default, classic, and TRMNL font families plus four text scales.
- Add a short-wide Weather composition for dense grids such as 800×480 at 3×3.
- Verify bundled Home Assistant brand icon dimensions in CI.

## 0.5.0

- Introduce self-contained widget packages under `opendisplay_studio/widgets`.
Expand Down
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,12 @@ media-source://opendisplay_studio/<project-id>

On every resolution the integration deduplicates widget data requirements,
reads current entity states, calendar events, and requested weather forecasts,
renders widget Liquid templates, composes one TRMNL Framework document, and
sends that final HTML to the Renderer. The Renderer App still knows only HTML,
width, and height. The designer preview calls this same Renderer path and shows
its PNG, so the visible preview and final Media Source do not have separate CSS
or layout implementations.
renders widget Liquid templates through
[`trmnl-liquid-py`](https://github.com/Misiu/trmnl-liquid-py), composes one
TRMNL Framework document, and sends that final HTML to the Renderer. The
Renderer App still knows only HTML, width, and height. The designer preview
calls this same Renderer path and shows its PNG, so the visible preview and
final Media Source do not have separate CSS or layout implementations.

See [the widget contract](WIDGET_CONTRACT.md) for the schema/data/template
boundary, native Home Assistant selectors, and the controlled provider model.
Expand Down
55 changes: 23 additions & 32 deletions STAGE2_COMPATIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,41 +8,32 @@ height. The App has no Liquid dependency or template API.

## Liquid engine

The integration pins `python-liquid==2.3.1`, a Python implementation tested
against Shopify's Golden Liquid suite. It runs in strict mode, has no filesystem
loader, auto-escapes variables, and applies limits for block depth, context
depth, loop iterations, local variables, and output bytes.

Supported in the POC:

- variables and nested mapping/list access;
- `assign`, `capture`, `if`/`elsif`/`else`, `unless`, `case`, `for`, `break`,
`continue`, and standard Shopify filters supplied by Python Liquid;
- selected TRMNL filters ported from `trmnl-liquid` 0.8.2:
`number_with_delimiter`, `json`, `parse_json`, `map_to_i`, `group_by`, and
`find_by`.

Not yet ported:

- TRMNL's custom `{% template %}` tag;
- `where_exp`, QR code, Markdown, locale/Rails helpers, currency, ordinal, and
timezone-specific filters;
- arbitrary user templates or Home Assistant entity bindings.

The official `usetrmnl/byos_node_lite` reference does not use Ruby either. It
uses LiquidJS with strict filters and variables inside its Node server. That is
useful compatibility evidence, but moving Liquid into this project's App would
violate the renderer-only boundary. A fuller Python port can become a separate
integration-side package after the POC.
The integration pins
[`trmnl-liquid-py==0.1.0`](https://github.com/Misiu/trmnl-liquid-py). The package
targets byte-for-byte compatibility with the supported non-I18n surface of Ruby
`trmnl-liquid` 0.8.2. Its differential corpus currently matches all 574 covered
Ruby outputs and maps all 73 upstream RSpec examples.

The shared package supplies the standard Liquid language, TRMNL's inline
`{% template %}` tag, Ruby-compatible lax syntax and runtime behavior, Markdown,
QR generation, and the supported TRMNL filters. Rendering intentionally follows
TRMNL's unescaped, lax semantics; missing provider fields become empty Liquid
values instead of failing an otherwise renderable display. Each widget fragment
uses a fresh in-memory environment, so inline template definitions cannot leak
between installed widgets and Liquid has no filesystem fallback.

Rails/ActionView behavior and full I18n implementations of `l_word` and `l_date`
remain outside the package's 0.1.0 compatibility target. Widget-owned translation
files and Home Assistant localization handle OpenDisplay Studio presentation
language independently of those Rails helpers.

## TRMNL Liquid Components

The stat/item, table, and progress structures were adapted from the MIT-licensed
`usetrmnl/trmnl-liquid-components` repository at commit
`77445360fb10b0d9edd2b28ffe574574ae563417`. Its custom `{% template %}` wrapper
was deliberately removed because that tag is outside the current subset. The
resulting HTML uses the same public Framework classes and renders as one DOM and
one screenshot.
`77445360fb10b0d9edd2b28ffe574574ae563417`. Installed widgets may now use the
same `{% template %}` and `{% render %}` primitives as TRMNL. The resulting HTML
uses the public Framework classes and renders as one DOM and one screenshot.

## Display profiles and palettes

Expand All @@ -61,5 +52,5 @@ not final e-paper quantization or dithering by OpenDisplay.

The App bundles TRMNL Framework 3.2.0 CSS/JS and fonts with upstream MIT, OFL,
and CC BY notices. It contains no Highcharts distribution or plugin image
bundle. The integration's Python Liquid dependency and the adapted Liquid
Components source are MIT licensed. No render path requires Internet access.
bundle. The integration's `trmnl-liquid-py` dependency and the adapted Liquid
Components source are MIT licensed.
6 changes: 6 additions & 0 deletions WIDGET_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,12 @@ The panel displays the PNG returned by the same renderer used for Media Source.
Its local widget component is only a temporary loading fallback; it is not a
second authoritative renderer.

Display-wide presentation is part of the project contract. `theme` maps to
the TRMNL light or dark screen mode, `fontFamily` selects the default, classic,
or TRMNL family, and `textScale` selects small, regular, large, or extra-large
framework typography. These settings must remain outside widget configuration
so every package shares one predictable display environment.

`--screen-w` and `--screen-h` describe the physical device. Responsive widget
decisions use the actual CSS region container dimensions, never names such as
full or half. A package must work for arbitrary device and region sizes.
Expand Down
47 changes: 33 additions & 14 deletions custom_components/opendisplay_studio/composer.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@

from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from liquid.exceptions import LiquidError
from trmnl_liquid import render as render_liquid

from .const import DOMAIN
from .liquid_renderer import LIQUID
from .projects import Project
from .widgets import DEFAULT_REGISTRY, WidgetRegistry, definition, with_defaults

Expand All @@ -32,10 +33,10 @@ class ProjectComposeError(Exception):

STUDIO_STYLES = """
<style>
.studio-screen{width:var(--studio-width)!important;height:var(--studio-height)!important;margin:0!important;padding:0!important;overflow:hidden!important;background:#fff;box-sizing:border-box}
.studio-screen{width:var(--studio-width)!important;height:var(--studio-height)!important;margin:0!important;padding:0!important;overflow:hidden!important;background:var(--framework-semantic-canvas-bg-color,#fff)!important;color:var(--framework-semantic-text-primary-text-color,#000);box-sizing:border-box}
.studio-screen .view--full{width:100%!important;height:100%!important;margin:0!important;padding:0!important;overflow:hidden!important}
.studio-grid{display:grid;width:100%;height:100%;padding:var(--studio-gap);gap:var(--studio-gap);box-sizing:border-box}
.studio-region{position:relative;min-width:0;min-height:0;overflow:hidden;background:#fff;box-sizing:border-box;container-type:size;container-name:od-region}
.studio-region{position:relative;min-width:0;min-height:0;overflow:hidden;background:var(--framework-semantic-surface-bg-color,transparent);color:inherit;box-sizing:border-box;container-type:size;container-name:od-region}
.studio-region>.item{width:100%!important;height:100%!important;margin:0!important;padding:0!important}
.studio-entity,.studio-entity__content{width:100%;height:100%;box-sizing:border-box}
.studio-entity__content{display:flex!important;align-items:center;justify-content:center;gap:clamp(4px,4cqh,14px);padding:clamp(7px,7cqh,22px)!important;overflow:hidden}
Expand Down Expand Up @@ -74,6 +75,20 @@ def _screen_size(width: int, height: int) -> str:
return "screen--md"


def _screen_preferences(project: Project) -> str:
"""Map persisted display preferences to TRMNL screen classes."""
classes: list[str] = []
if project.get("theme", "light") == "dark":
classes.append("screen--dark-mode")
font_family = project.get("fontFamily", "default")
if font_family in {"classic", "trmnl"}:
classes.append(f"screen--fonts-{font_family}")
text_scale = project.get("textScale", "regular")
if text_scale in {"small", "large", "xlarge"}:
classes.append(f"screen--text-scale-{text_scale}")
return " ".join(classes)


def _region_size(
project: Project, region: dict[str, Any], *, gap: int
) -> tuple[float, float]:
Expand Down Expand Up @@ -204,16 +219,18 @@ async def async_compose_project(
resolved,
registry,
)
result = LIQUID.render(
registry.template(widget_type),
{
"config": config,
"data": data,
"region": {"shape": _region_shape(project, region, gap=layout_gap)},
},
)
fragment = result.html
liquid_ms += result.milliseconds
liquid_started = perf_counter()
try:
fragment = render_liquid(
registry.template(widget_type),
config=config,
data=data,
region={"shape": _region_shape(project, region, gap=layout_gap)},
)
except LiquidError as err:
message = f"Could not render {widget_type} widget: {err}"
raise ProjectComposeError(message) from err
liquid_ms += (perf_counter() - liquid_started) * 1_000
region_width, region_height = _region_size(project, region, gap=layout_gap)
ratio = region_width / max(1, region_height)
style = (
Expand Down Expand Up @@ -241,8 +258,10 @@ async def async_compose_project(
body = "".join(fragments)
size = _screen_size(width, height)
portrait = " screen--portrait" if height > width else ""
preferences = _screen_preferences(project)
preference_classes = f" {preferences}" if preferences else ""
html = (
f'<main class="screen {mode} {size}{portrait} studio-screen" '
f'<main class="screen {mode} {size}{portrait}{preference_classes} studio-screen" '
f'style="--studio-width:{width}px;--studio-height:{height}px;'
f"--screen-w:{width}px;--screen-h:{height}px;"
f'--studio-gap:{layout_gap}px">{STUDIO_STYLES}<div class="view view--full">'
Expand Down
2 changes: 1 addition & 1 deletion custom_components/opendisplay_studio/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

DOMAIN = "opendisplay_studio"
NAME = "OpenDisplay Studio"
INTEGRATION_VERSION = "0.5.0"
INTEGRATION_VERSION = "0.5.1"

API_VERSION = 1
MIN_RENDERER_VERSION = "0.5.0"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2703,6 +2703,9 @@ var tt = (e) => e <= 1.6 ? {
name: e,
status: "draft",
language: t,
theme: "light",
fontFamily: "default",
textScale: "regular",
displayId: n.id,
orientation: r,
palette: n.defaultPalette,
Expand Down Expand Up @@ -3469,6 +3472,26 @@ var Z = (e, t) => {
palette: t
}));
}
changeTheme(e) {
this.updateLayoutDraft((t) => ({
...t,
theme: e
}));
}
changeFontFamily(e) {
let t = e.currentTarget.value;
this.updateLayoutDraft((e) => ({
...e,
fontFamily: t
}));
}
changeTextScale(e) {
let t = e.currentTarget.value;
this.updateLayoutDraft((e) => ({
...e,
textScale: t
}));
}
changeOrientation(e) {
if (e === this.canvasProject.orientation) return;
let t = this.canvasProject.displayId === "custom" ? {
Expand Down Expand Up @@ -3656,6 +3679,30 @@ var Z = (e, t) => {
${(e.displayId === "custom" ? Object.keys(R) : t.palettes).map((e) => C`<option value=${e}>${R[e]}</option>`)}
</select>
</div>
<div class="control">
<span class="field-label">Theme</span>
<div class="segment" role="group" aria-label="Display theme">
<button class=${e.theme === "light" ? "active" : ""} @click=${() => this.changeTheme("light")}>Light</button>
<button class=${e.theme === "dark" ? "active" : ""} @click=${() => this.changeTheme("dark")}>Dark</button>
</div>
</div>
<div class="control">
<label for="font-family">Font family</label>
<select id="font-family" .value=${e.fontFamily} @change=${this.changeFontFamily}>
<option value="default">Default</option>
<option value="classic">Classic</option>
<option value="trmnl">TRMNL</option>
</select>
</div>
<div class="control">
<label for="text-scale">Text scale</label>
<select id="text-scale" .value=${e.textScale} @change=${this.changeTextScale}>
<option value="small">Small</option>
<option value="regular">Regular</option>
<option value="large">Large</option>
<option value="xlarge">Extra large</option>
</select>
</div>
<div class="control">
<span class="field-label">Orientation</span>
<div class="segment" role="group" aria-label="Display orientation">
Expand All @@ -3678,7 +3725,7 @@ var Z = (e, t) => {
<div class="device-summary">
<span class="step-kicker">Step 2 · Widgets</span>
<strong>${this.displayName(this.project)}</strong>
<span>${e.width}×${e.height} · ${R[this.project.palette]} · ${this.project.grid.columns}×${this.project.grid.rows} grid</span>
<span>${e.width}×${e.height} · ${R[this.project.palette]} · ${this.project.theme} · ${this.project.fontFamily}/${this.project.textScale} · ${this.project.grid.columns}×${this.project.grid.rows} grid</span>
</div>
<ha-button size="s" appearance="outlined" @click=${this.openLayoutEditor}>${J(Qe)} Edit device & layout</ha-button>
</div>
Expand Down
Loading