Skip to content

Commit a46780d

Browse files
cryptomilkclaude
andcommitted
feat(heading): add text alignment option
Weekday-style headings (Monday vs. Friday) shift position as their text length changes; right-aligning keeps the end of the text at a consistent spot. Adds heading_align ("left"/"right") to the Heading widget, following the same anchor pattern as the Entity widget's name_align — right-aligned text anchors to the space left of any placed badges, or the right content edge when there are none, while the icon stays left-anchored. Fixes #84 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 19092cc commit a46780d

5 files changed

Lines changed: 241 additions & 6 deletions

File tree

custom_components/eink_dashboard/frontend/src/eink-dashboard-editor.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ export const WIDGET_TYPES: Record<string, WidgetTypeMeta> = {
6969
h: 56,
7070
heading: "",
7171
heading_style: "title",
72+
heading_align: "left",
7273
icon_style: "none",
7374
card_style: DEFAULT_CARD_STYLE,
7475
},
@@ -428,6 +429,26 @@ function nameAlignSelector(): HaFormSchema {
428429
};
429430
}
430431

432+
/**
433+
* Heading text alignment dropdown selector.
434+
*
435+
* @returns A single ha-form schema entry.
436+
*/
437+
function headingAlignSelector(): HaFormSchema {
438+
return {
439+
name: "heading_align",
440+
default: "left",
441+
selector: {
442+
select: {
443+
options: [
444+
{ value: "left", label: "Left" },
445+
{ value: "right", label: "Right" },
446+
],
447+
},
448+
},
449+
};
450+
}
451+
431452
/**
432453
* Icon style dropdown selector.
433454
*
@@ -903,7 +924,11 @@ export const SCHEMAS: Record<
903924
flatten: true,
904925
title: "Appearance",
905926
icon: "mdi:palette",
906-
schema: [cardStyleSelector(), iconStyleSelector("none")],
927+
schema: [
928+
cardStyleSelector(),
929+
iconStyleSelector("none"),
930+
headingAlignSelector(),
931+
],
907932
},
908933
],
909934

@@ -1553,6 +1578,7 @@ export const LABELS: Record<string, string> = {
15531578
unit: "Unit",
15541579
heading: "Heading",
15551580
heading_style: "Heading style",
1581+
heading_align: "Heading alignment",
15561582
badges: "Badges",
15571583
card_style: "Card style",
15581584
icon_style: "Icon style",

custom_components/eink_dashboard/frontend/src/types/ha.d.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -585,6 +585,15 @@ export interface HeadingWidget extends WidgetBase {
585585
* - ``"subtitle"`` — smaller Roboto Regular in gray.
586586
*/
587587
heading_style?: "title" | "subtitle";
588+
/**
589+
* Horizontal text alignment.
590+
* - ``"left"`` — text starts at the icon-derived left edge
591+
* (default).
592+
* - ``"right"`` — text ends at the space left of any badges, or
593+
* the right content edge when there are none. The icon, if
594+
* present, stays left-anchored.
595+
*/
596+
heading_align?: "left" | "right";
588597
/** MDI icon name rendered to the left of the text (e.g. "mdi:home"). */
589598
icon?: string;
590599
/**

custom_components/eink_dashboard/templates/heading.svg.j2

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
font-weight="{{ font_weight }}"
2424
font-size="{{ font_sz }}"
2525
dominant-baseline="central"
26+
text-anchor="{{ text_anchor }}"
2627
fill="{{ text_fill }}">{{ heading }}</text>
2728
{%- endif -%}
2829
{%- for badge in badges -%}

custom_components/eink_dashboard/widgets/heading.py

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -80,10 +80,21 @@ def _build_heading_context(
8080
Badges are resolved right-to-left from the right edge; any badge
8181
that would overlap the heading text is silently omitted.
8282
83+
``heading_align`` controls the heading text's horizontal anchor:
84+
85+
- ``"left"`` — text starts at the icon-derived left edge
86+
(default).
87+
- ``"right"`` — text ends at the space left of any placed
88+
badges, or the right content edge when there are none. The
89+
icon, if present, stays left-anchored. If the text is too
90+
wide to fit in that space, its left edge is clamped to the
91+
icon-derived left edge instead of drawing over the icon.
92+
8393
Args:
8494
widget: Widget config dict. Recognised keys:
8595
``heading`` (display text, default ``""``),
8696
``heading_style`` (``"title"`` / ``"subtitle"``),
97+
``heading_align`` (``"left"`` / ``"right"``),
8798
``icon`` (MDI icon name, e.g. ``"mdi:home"``),
8899
``icon_style`` (``"none"`` / ``"filled"`` /
89100
``"outlined"``),
@@ -111,6 +122,7 @@ def _build_heading_context(
111122
svg_h = _widget_dim(widget, "h", DEFAULT_ROW_H)
112123
heading_text: str = widget.get("heading", "")
113124
heading_style: str = widget.get("heading_style", "title")
125+
heading_align: str = widget.get("heading_align", "left")
114126
icon_override = widget.get("icon")
115127
icon_style: str = widget.get("icon_style", "none")
116128
raw_badges = widget.get("badges", [])
@@ -160,7 +172,7 @@ def _build_heading_context(
160172
# edge.
161173
icon_glyph_x = content_left
162174
icon_glyph_y = svg_h // 2 - glyph_sz // 2
163-
text_x = content_left + glyph_sz + m.inner_gap
175+
min_text_x = content_left + glyph_sz + m.inner_gap
164176
else:
165177
# Circle style: glyph is centred inside the circle.
166178
r = m.icon_dia // 2
@@ -170,9 +182,9 @@ def _build_heading_context(
170182
icon_fill = color_to_hex(COLOR_GRAY)
171183
icon_glyph_x = icon_cx - glyph_sz // 2
172184
icon_glyph_y = icon_cy - glyph_sz // 2
173-
text_x = content_left + m.icon_dia + m.inner_gap
185+
min_text_x = content_left + m.icon_dia + m.inner_gap
174186
else:
175-
text_x = content_left
187+
min_text_x = content_left
176188

177189
text_y = svg_h // 2
178190

@@ -235,12 +247,15 @@ def _build_heading_context(
235247
# Position badges right-to-left. Once a badge cannot fit
236248
# (its left edge would overlap the heading text), it and
237249
# all remaining badges to its left in config order are
238-
# dropped.
250+
# dropped. The overlap boundary is always the icon-derived
251+
# left edge (``min_text_x``), regardless of ``heading_align``,
252+
# since that is where left-aligned text starts and where
253+
# right-aligned text is clamped to below.
239254
badge_right = svg_w - r_inset - rpad
240255
rendered_badges: list[dict[str, object]] = []
241256
for bd in reversed(badge_data):
242257
new_right = badge_right - bd.total_w
243-
if new_right < text_x + m.inner_gap:
258+
if new_right < min_text_x + m.inner_gap:
244259
break
245260
badge_cy = svg_h // 2
246261
rendered_badges.insert(
@@ -257,6 +272,23 @@ def _build_heading_context(
257272
)
258273
badge_right = new_right - m.inner_gap
259274

275+
# Right alignment anchors the text to the space left of any
276+
# placed badges (or the right content edge when there are none),
277+
# rather than the icon-derived left edge. If the heading text is
278+
# wide enough that anchoring it there would push its left edge
279+
# past ``min_text_x``, clamp the anchor so the text never draws
280+
# over the icon — matching the left-aligned case, where text
281+
# simply grows rightward from ``min_text_x`` instead.
282+
text_x = min_text_x
283+
text_anchor = "start"
284+
if heading_align == "right":
285+
text_anchor = "end"
286+
text_x = badge_right
287+
if heading_text:
288+
heading_font = _load_font(font_sz, medium=is_title)
289+
text_w = round(heading_font.getlength(heading_text))
290+
text_x = max(text_x - text_w, min_text_x) + text_w
291+
260292
has_content = bool(heading_text) or bool(icon_svg) or bool(rendered_badges)
261293

262294
if not has_content:
@@ -294,6 +326,7 @@ def _build_heading_context(
294326
"text_fill": text_fill,
295327
"text_x": text_x,
296328
"text_y": text_y,
329+
"text_anchor": text_anchor,
297330
# Badges (pre-positioned, empty list when none fit).
298331
"badges": rendered_badges,
299332
"badge_font_sz": badge_font_sz,

tests/test_render_heading.py

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -527,6 +527,172 @@ def test_heading_icon_style_none_is_default(self) -> None:
527527
without = render_dashboard([base], self._config())
528528
assert with_none == without
529529

530+
# ── Alignment tests ───────────────────────────────
531+
532+
def test_heading_align_left_is_default(self) -> None:
533+
# Omitting heading_align produces byte-identical output to
534+
# heading_align="left".
535+
base = {
536+
"type": "heading",
537+
"x": 0,
538+
"y": 0,
539+
"w": 400,
540+
"h": 56,
541+
"heading": "Section",
542+
}
543+
with_left = render_dashboard(
544+
[{**base, "heading_align": "left"}], self._config()
545+
)
546+
without = render_dashboard([base], self._config())
547+
assert with_left == without
548+
549+
def test_heading_align_right_changes_output(self) -> None:
550+
# heading_align="right" changes the rendered output relative
551+
# to the default left alignment.
552+
base = {
553+
"type": "heading",
554+
"x": 0,
555+
"y": 0,
556+
"w": 400,
557+
"h": 56,
558+
"heading": "Monday",
559+
}
560+
left = render_dashboard([base], self._config())
561+
right = render_dashboard(
562+
[{**base, "heading_align": "right"}], self._config()
563+
)
564+
assert left != right, (
565+
"heading_align='right' should change rendered output"
566+
)
567+
568+
def test_heading_align_right_text_near_right_edge(self) -> None:
569+
# Right-aligned text has its rightmost ink near the right
570+
# content edge of the widget.
571+
m = _compute_metrics(56)
572+
widgets = [
573+
{
574+
"type": "heading",
575+
"x": 0,
576+
"y": 0,
577+
"w": 400,
578+
"h": 56,
579+
"heading": "Mon",
580+
"heading_align": "right",
581+
}
582+
]
583+
img = render_to_image(widgets, self._config())
584+
right_edge = 400 - m.padding
585+
bb = content_bbox(img, 0, 0, 400, 56)
586+
assert bb is not None, "right-aligned heading should draw content"
587+
assert bb[2] >= right_edge - 5, (
588+
"heading_align='right' should place text near right edge"
589+
)
590+
591+
def test_heading_align_right_with_icon(self) -> None:
592+
# With an icon present, the icon stays left-anchored while
593+
# the text anchors to the right edge.
594+
m = _compute_metrics(56)
595+
widgets = [
596+
{
597+
"type": "heading",
598+
"x": 0,
599+
"y": 0,
600+
"w": 400,
601+
"h": 56,
602+
"heading": "Mon",
603+
"icon": "mdi:home",
604+
"heading_align": "right",
605+
}
606+
]
607+
img = render_to_image(widgets, self._config())
608+
# Icon draws dark pixels on the left.
609+
assert_has_dark_pixels(
610+
img, m.padding, 4, m.padding + 30, 52, threshold=200
611+
)
612+
# Text draws dark pixels near the right edge.
613+
right_edge = 400 - m.padding
614+
bb = content_bbox(img, 100, 0, 400, 56)
615+
assert bb is not None
616+
assert bb[2] >= right_edge - 5, (
617+
"right-aligned text should reach the right edge"
618+
)
619+
620+
def test_heading_align_right_long_text_clamps_to_icon(self) -> None:
621+
# A heading too wide to fit between the icon and the right
622+
# edge must not draw over the icon; its left edge clamps to
623+
# the icon-derived boundary instead of extending further
624+
# left, as it would with an unbounded right anchor.
625+
m = _compute_metrics(56)
626+
widgets = [
627+
{
628+
"type": "heading",
629+
"x": 0,
630+
"y": 0,
631+
"w": 400,
632+
"h": 56,
633+
"heading": "A Really Very Long Heading Text That Overflows",
634+
"icon": "mdi:home",
635+
"heading_align": "right",
636+
}
637+
]
638+
img = render_to_image(widgets, self._config())
639+
# Icon still draws dark pixels on the left.
640+
assert_has_dark_pixels(
641+
img, m.padding, 4, m.padding + 30, 52, threshold=200
642+
)
643+
# The gap between the icon and the text's clamped left edge
644+
# stays white — text does not creep left over the icon.
645+
glyph_sz = max(10, m.font_primary)
646+
icon_right = m.padding + glyph_sz
647+
min_text_x = icon_right + m.inner_gap
648+
assert_all_white(img, icon_right, 0, min_text_x, 56)
649+
650+
def test_heading_align_right_with_badges(self) -> None:
651+
# With badges present, right-aligned text anchors to the left
652+
# of the badge area rather than the widget edge — badges
653+
# change where the text lands.
654+
base = {
655+
"type": "heading",
656+
"x": 0,
657+
"y": 0,
658+
"w": 400,
659+
"h": 56,
660+
"heading": "A",
661+
"heading_align": "right",
662+
"badges": ["sensor.temperature"],
663+
}
664+
without_badges = render_dashboard(
665+
[{**base, "badges": []}], self._config()
666+
)
667+
with_badges = render_dashboard([base], self._config())
668+
assert without_badges != with_badges, (
669+
"badges should shift right-aligned text anchor leftward"
670+
)
671+
672+
def test_heading_align_right_with_card_border(self) -> None:
673+
# With card_style="border", right-aligned text respects the
674+
# border's right inset rather than the widget's raw edge.
675+
m = _compute_metrics(56)
676+
widgets = [
677+
{
678+
"type": "heading",
679+
"x": 0,
680+
"y": 0,
681+
"w": 400,
682+
"h": 56,
683+
"heading": "Mon",
684+
"heading_align": "right",
685+
"card_style": "border",
686+
}
687+
]
688+
img = render_to_image(widgets, self._config())
689+
right_edge = 400 - m.padding
690+
bb = content_bbox(img, m.padding, 4, 400 - m.padding, 52)
691+
assert bb is not None
692+
assert bb[2] >= right_edge - 5, (
693+
"right-aligned text should respect border inset"
694+
)
695+
530696
# ── Badge tests ───────────────────────────────────
531697

532698
def test_heading_badges_change_output(self) -> None:

0 commit comments

Comments
 (0)