--- name: implement-widget description: "Implement a widget's SVG template and Python context builder. Creates templates/{type}.svg.j2 and widgets/{type}.py with _build_{type}_context(). Follows TDD green phase — makes existing tests pass." when_to_use: "When implementing the SVG template and context builder for a new widget type. Always run AFTER tests are written (TDD green phase)." argument-hint: "[widget-type]" arguments: widget-type allowed-tools: Read, Edit, Write, Bash(uv *) --- # Implement Widget Renderer: $widget-type Implement the SVG template and Python context builder for **$widget-type**. The output of this work is two artifacts: 1. `custom_components/eink_dashboard/templates/$widget-type.svg.j2` 2. `_build_$widget-type_context()` in `widgets/$widget-type.py` ## Before you start 1. Read the existing tests (`TestRender{WidgetName}` in `tests/test_render_{widget_type}.py`) — these define the expected behavior. 2. Read `const.py` for `WidgetType`, `COLOR_*` constants, `PADDING`, `DEFAULT_CARD_STYLE`, `Widget`, `DisplayConfig`, `color_to_hex`. 3. Read `svg_render.py` for the Jinja2 environment, `_svg_to_png()`, icon filter functions (`_mdi_svg_filter`, `_weather_svg_filter`), and `_SVG_RENDERERS`. 4. Read existing widget modules in `widgets/` (e.g. `widgets/tile.py`) for the context builder pattern, and `widgets/_helpers.py` for shared layout helpers. 5. Read `templates/_macros.svg.j2` for the three shared macros: `card_container`, `card_row`, `chip`. ## Current SVG infrastructure (svg_render.py) !`grep -n "^def \|^_SVG_RENDERERS\|^_jinja_env\|^_TEMPLATE_DIR\|^_FONTS_DIR\|^_mdi\|^_weather" custom_components/eink_dashboard/svg_render.py` ## Shared layout helpers (widgets/_helpers.py) !`grep -n "^def \|^_ACTIVE" custom_components/eink_dashboard/widgets/_helpers.py` ## Existing widget context builders !`grep -rn "^def _build_" custom_components/eink_dashboard/widgets/*.py` ## SVG macro signatures !`grep -n "macro card_container\|macro card_row\|macro chip\|macro icon_circle" custom_components/eink_dashboard/templates/_macros.svg.j2` ## WidgetMetrics and _compute_metrics (in render.py) !`grep -n "^class WidgetMetrics" -A 10 custom_components/eink_dashboard/render.py` !`grep -n "^def _compute_metrics" -A 12 custom_components/eink_dashboard/render.py` ## Icon name resolver (in render.py) !`grep -n "^def _device_class_icon" -A 6 custom_components/eink_dashboard/render.py` ## Current SVG renderer registry !`grep -n "_SVG_RENDERERS" -A 12 custom_components/eink_dashboard/svg_render.py` ## Imports Context builders live in `widgets/{type}.py` and use relative imports. `_compute_metrics`, `_device_class_icon`, and `_load_font` live in `render.py`. `color_to_hex`, `Widget`, `DisplayConfig`, and constants live in `const.py`. Layout helpers live in `widgets/_helpers.py`. ```python from ..const import ( DEFAULT_CARD_STYLE, DEFAULT_ROW_H, PADDING, DisplayConfig, Widget, color_to_hex, ) from ..render import ( _compute_metrics, _device_class_icon, _load_font, # chip width measurement only ) from ..svg_render import _mdi_svg_filter from ._helpers import ( _auto_row_height, _card_insets, _color_context, _fmt, _metrics_context, _widget_dim, ) ``` Use `_fmt(state_val, config)` whenever a context builder formats a numeric entity state for display. This applies locale-aware decimal and thousands separators matching the owner's HA preference. Pass the raw state string — non-numeric strings (``"on"``, ``"unavailable"``) are returned unchanged. `_color_context()` in `widgets/_helpers.py` returns `{"hex_black": ..., "hex_white": ..., "hex_gray": ...}` by calling `color_to_hex()` on the `const.py` integer constants. Spread it into the context dict with `**_color_context()` so templates can use `{{ hex_gray }}` instead of hardcoded hex literals. ## Macro usage notes - **`card_container`** uses Jinja2 `{% call %}` syntax. The macro passes `(x_off, right_inset)` back to the caller body: `"border"` → `(m.padding, m.padding)`; `"left_bar"` → `(bar_w + m.padding, 0)`; `"none"` → `(0, 0)`. Content starts at `x_off`; content width = `w - x_off - right_inset`. Always pass `display_levels` from config. - **`card_row`** expects all sizes from `WidgetMetrics` fields plus `icon_svg` (pre-built SVG string, empty string for letter fallback) and `letter` (single uppercase char, empty string when icon_svg is set). `primary` is the entity label; `secondary` is the state + unit. Pass `lpad`/`rpad` derived from `_card_insets()` to prevent double-padding when a card style already provides insets; use the same values for `x1`/`x2` of any divider `` between rows. Pass `icon_no_circle=True` when `icon_style == "none"` — the macro hides the circle but still renders the icon glyph. - **`chip`** requires pre-computed `w` because text width depends on font metrics unavailable in Jinja2. The context builder must compute chip widths using `_load_font(size).getlength(text)` from PIL — the font object is used only for measurement, not for drawing. - **Icon filters** generate the SVG string in Python. Call `_mdi_svg_filter(name, size)` in the context builder and pass the result as the `icon_svg` string to the template. Do NOT call filters from inside templates — pass the pre-built string via context. - **`_device_class_icon(attrs, state_val, domain)`** returns an MDI icon name without the `"mdi:"` prefix, or `None`. Extract domain: `entity_id.split(".")[0]`. ## Implementation steps ### 1. Add to WidgetType enum (if new widget) In `const.py`, add the new value to `WidgetType`. Keep alphabetical order. ### 2. Create the SVG template Create `custom_components/eink_dashboard/templates/$widget-type.svg.j2`. The template receives a pre-computed context dict from the Python context builder. All layout math (positions, sizes, icon SVG strings, chip widths) is computed in Python. Templates only emit SVG markup. Widget SVGs are composited onto a white canvas using the alpha channel as a mask (`render_dashboard()` in `render.py`). Transparent pixels preserve whatever is already on the canvas (white by default), so most templates do **not** need a white background rect. Add a full-viewport white background rect **only** when the template's rendered geometry is smaller than its SVG viewport — i.e. when content does not fill `w` × `h`. The separator is the canonical example: its bar (`bar_w` × `bar_h`) is much smaller than the widget viewport, so surrounding transparent area would let earlier widget content bleed through without the rect. Card-style and chip-style templates omit the rect because the card chrome (via `card_container`) or chip flow covers the meaningful area, and any remaining transparent edge pixels are harmless (canvas is white). **Card-style template pattern** (tile, waste_schedule): ```jinja {%- from "_macros.svg.j2" import card_container, card_row -%} {%- call(x_off, r_inset) card_container( x=0, y=0, w=w, h=h, card_style=card_style, radius=m.radius, border=m.border, padding=m.padding, left_bar=m.left_bar, display_levels=display_levels) -%} {%- for row in rows -%} {{ card_row( x=x_off, y=row.y, w=w - x_off - r_inset, row_h=row_h, padding=m.padding, icon_dia=m.icon_dia, inner_gap=m.inner_gap, border=m.border, font_primary=m.font_primary, font_secondary=m.font_secondary, primary=row.primary, secondary=row.secondary, value=row.value, icon_svg=row.icon_svg, letter=row.letter, lpad=lpad, rpad=rpad) }} {%- if not loop.last -%} {%- endif -%} {%- endfor -%} {%- endcall -%} ``` **Chip-style template pattern** (device_battery): ```jinja {%- from "_macros.svg.j2" import chip -%} {%- for c in chips -%} {{ chip( x=c.x, y=c.y, w=c.w, h=chip_h, text=c.text, border=m.border, icon_svg=c.icon_svg, inverted=c.inverted) }} {%- endfor -%} ``` **Simple template pattern** (separator): When rendered geometry (e.g. a bar or line) is smaller than the SVG viewport, include a full-size white rect before it so the surrounding area is not transparent: ```jinja ``` Template coordinates are relative to origin (0, 0) — the outer composition handles absolute `x`/`y` placement on the dashboard. ### 3. Write the context builder Create `widgets/$widget-type.py` with `_build_$widget-type_context(widget, config) -> dict`. The context builder: - Extracts widget dimensions and config from `widget` and `config` - Calls `_compute_metrics(row_h)` to get `WidgetMetrics` - Calls `_device_class_icon()` to resolve icon names - Calls `_mdi_svg_filter(name, size)` to build icon SVG strings - Computes all positions, sizes, and data — returns a plain dict The template receives only this dict; no layout math happens in Jinja2. **Card-style context builder pattern:** ```python def _build_{widget_type}_context( widget: Widget, config: DisplayConfig, ) -> dict[str, object]: """Build template context for $widget-type widget. Args: widget: Widget config dict with x, w, h, entities, card_style, title. config: DisplayConfig with states and display_levels. Returns: Template context dict. """ x = widget.get("x", PADDING) w = _widget_dim(widget, "w", config["width"] - x) title: str = widget.get("title", "") card_style = widget.get( "card_style", DEFAULT_CARD_STYLE ) entities = widget.get("entities", []) states = config.get("states", {}) display_levels = config.get("display_levels", 16) n = len(entities) if n == 0: return { "w": w, "h": _widget_dim(widget, "h", DEFAULT_ROW_H), "rows": [], } # Row-based: auto-size height to fit n rows at # DEFAULT_ROW_H px each. An explicit "h" overrides this. h = _widget_dim(widget, "h", _auto_row_height(title, n)) row_h = h // n m = _compute_metrics(row_h) x_off, r_inset, bar_width = _card_insets( m, card_style, display_levels ) lpad = m.padding if x_off == 0 else 0 rpad = m.padding if r_inset == 0 else 0 rows: list[dict[str, object]] = [] for i, entity_id in enumerate(entities): state = states.get(entity_id) if state is None: continue attrs = state.get("attributes", {}) domain = entity_id.split(".")[0] # Resolve icon: device_class → attrs["icon"] → letter. icon_name = _device_class_icon( attrs, state["state"], domain ) if icon_name is None: raw = attrs.get("icon", "") if raw.startswith("mdi:"): icon_name = raw[4:] # Call filter in Python; suppress missing-icon errors. icon_svg: markupsafe.Markup | str = "" if icon_name: with contextlib.suppress(FileNotFoundError): icon_svg = _mdi_svg_filter( icon_name, m.icon_inner ) letter = "" if not icon_svg: friendly = attrs.get("friendly_name", entity_id) letter = friendly[:1].upper() if friendly else "" unit = attrs.get("unit_of_measurement", "") state_val = state["state"] rows.append({ "y": i * row_h, "primary": attrs.get( "friendly_name", entity_id ), "secondary": f"{_fmt(state_val, config)}{unit}", "value": "", "icon_svg": icon_svg, "letter": letter, }) return { "w": w, "h": h, "card_style": card_style, "display_levels": display_levels, "rows": rows, "row_h": row_h, "lpad": lpad, "rpad": rpad, **_metrics_context(m), **_color_context(), } ``` **Chip-style context builder — chip width computation:** Chips require pre-computed widths because Jinja2 cannot measure text. Use a PIL font object (Roboto at `round(h * 0.46)`) only for measurement — `_load_font` is LRU-cached, so this is cheap: ```python def _build_{widget_type}_context( widget: Widget, config: DisplayConfig, ) -> dict[str, object]: """Build template context for $widget-type widget.""" x = widget.get("x", PADDING) w = _widget_dim(widget, "w", config["width"] - x) h = _widget_dim(widget, "h", DEFAULT_ROW_H) entities = widget.get("entities", []) states = config.get("states", {}) m = _compute_metrics(h) # pad, icon_gap, and font_sz are chip-specific ratios; # icon_dia and icon_inner come from _compute_metrics() so # they match card_row at the same height. pad = round(h * 0.18) icon_gap = round(h * 0.14) font_size = max(10, round(h * 0.46)) font = _load_font(font_size) # PIL, for width measurement only. chip_gap = m.border + 4 chips: list[dict[str, object]] = [] for entity_id in entities: state = states.get(entity_id) if state is None: continue attrs = state.get("attributes", {}) domain = entity_id.split(".")[0] icon_name = _device_class_icon( attrs, state["state"], domain ) if icon_name is None: raw = attrs.get("icon", "") if raw.startswith("mdi:"): icon_name = raw[4:] icon_svg: markupsafe.Markup | str = "" if icon_name: with contextlib.suppress(FileNotFoundError): icon_svg = _mdi_svg_filter( icon_name, m.icon_inner ) label = attrs.get("friendly_name", entity_id) text_w = round(font.getlength(label)) icon_w = (m.icon_dia + icon_gap) if icon_svg else 0 chip_w = pad + icon_w + text_w + pad chips.append({ "text": label, "icon_svg": icon_svg, "inverted": state["state"] == "on", "w": chip_w, }) # Flow layout: pack chips left-to-right with wrapping. cx, cy = 0, 0 for c in chips: if cx > 0 and cx + c["w"] > w: cx, cy = 0, cy + h + chip_gap c["x"] = cx c["y"] = cy cx += c["w"] + chip_gap return { "w": w, "h": h, "m": m, "chip_h": h, "chips": chips, } ``` ### 4. Re-export from widgets/__init__.py In `widgets/__init__.py`, add the import and `__all__` entry: ```python from .{widget_type} import _build_{widget_type}_context __all__ = [ ... "_build_{widget_type}_context", ] ``` ### 5. Register in _SVG_RENDERERS In `svg_render.py`, import from `widgets` and add to the `_SVG_RENDERERS` dict: ```python from .widgets import _build_{widget_type}_context _SVG_RENDERERS: dict[str, SvgContextFn] = { ... WidgetType.{WIDGET_TYPE}: _build_{widget_type}_context, } ``` ### 6. Run tests ```bash uv run --group lint ruff check . && \ uv run --group format ruff format --check . && \ uv run --group typecheck ty check && \ uv run --group test pytest ``` All tests must pass. Fix any failures — the tests define correct behavior. ## Key constraints - **No `font_size` parameter.** All sizes derive from `w` and `h` via `_compute_metrics()`. TEXT widget is the only exception. - **Chip proportions from `WidgetMetrics`.** `icon_dia` and `icon_inner` come from `_compute_metrics(chip_h)` — do not derive them from inline ratios or `_CHIP_*_RATIO` constants (removed in step 0.5). `pad`, `icon_gap`, and `font_sz` are chip-specific and have no `WidgetMetrics` field. - **Use shared macros.** Do not duplicate card/chip/row SVG markup. Use `card_container`, `card_row`, `chip` from `_macros.svg.j2`. - **All layout math in Python.** Templates receive final coordinates and data. No arithmetic or conditionals in Jinja2 beyond what the macros already compute internally. - **White background rect.** Only add `` when the template's rendered geometry is smaller than its SVG viewport (e.g. separator bar). Card-style and chip-style templates omit it — the canvas is pre-filled white and alpha masking handles compositing. - **Missing state = skip.** If `states.get(entity_id)` returns `None`, skip the entity and continue. Do not crash. - **Icon inlining via filter functions.** Call `_mdi_svg_filter(name, size)` or `_weather_svg_filter(condition, size)` in the context builder and pass the returned string as `icon_svg`. - **Font references in SVG.** Use `font-family="Roboto"` and `font-weight="500"` for Roboto Medium. No `_load_font()` calls in templates. - **Template coordinates are relative.** Compute row `y` positions relative to 0, not to the widget's absolute position on the dashboard. Outer composition handles `x`/`y` placement. - **Line length: 79 chars.** Applies to Python, Jinja2 templates, and docstrings. ## Key references - SVG macros: `card_container`, `card_row`, `chip` in `templates/_macros.svg.j2` - SVG pipeline: `_jinja_env`, `_svg_to_png()`, `_mdi_svg_filter()`, `_weather_svg_filter()` in `svg_render.py` - Layout metrics: `_compute_metrics()`, `WidgetMetrics` in `render.py` - Icon name resolution: `_device_class_icon()` in `render.py` - Colors: `COLOR_BLACK=0`, `COLOR_WHITE=255`, `COLOR_GRAY=120`, `COLOR_LIGHT_GRAY=180` in `const.py` - Layout helpers: `_widget_dim`, `_auto_row_height`, `_card_insets`, `_metrics_context`, `_color_context`, `_fmt`, `_entity_info_context`, `_title_layout` in `widgets/_helpers.py` - Constants: `DEFAULT_ROW_H`, `Widget`, `DisplayConfig`, `color_to_hex` in `const.py` - Icon style pattern: `_ACTIVE_STATES` frozenset (in `widgets/_helpers.py`) determines active vs inactive state; use `contextlib.suppress(FileNotFoundError)` around every `_mdi_svg_filter()` call - **Custom icon circle**: When a widget renders its own icon circle (not via the `card_row` macro), use the `icon_circle` macro from `_macros.svg.j2` — it handles filled/outlined/no-circle variants and glyph/letter rendering. The context builder must pass `icon_cx`, `icon_cy`, `icon_r`, `icon_glyph_x`, `icon_glyph_y`, `icon_stroke_w`, `icon_fill`, `icon_color`, `icon_svg`, `icon_outline`, `icon_no_circle`, `letter`, and `letter_font_sz` to the template. Widen the outline stroke on 2-level displays to avoid dithering — compute this in the context builder and pass it as `icon_stroke_w`: ```python # Widen the outline stroke on 2-level displays. icon_stroke_w = ( m.border * 3 if display_levels <= 2 else m.border ) ``` The `card_row` macro handles this widening internally for row icons. Only apply it explicitly when using `icon_circle` directly.