Keep a long alert title on one line, ending in an ellipsis (0.2.64)

The title label had long_mode: DOT and a width but no height. LVGL 9.5 only
puts the dots on a label whose wrapped text is taller than the label, so the
label grew with its lines and a long title ran into the subtitle. It now has
one line of the headline font as its height (32 px Guition, 21 px CYD).

The Claude skill and the editor's alert tips said the CYD shows about forty
characters of a title; both screens show about twenty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-17 11:12:54 +02:00
parent bc2a754706
commit e68162bd86
12 changed files with 145 additions and 9 deletions
+19
View File
@@ -176,6 +176,25 @@ icons sit off-center in the browser); run `generate_packages.py` afterward.
The `MDI_GLYPH_*` substitutions are retired: `TILEn_ICON` in manual
profiles must come from the set. No changed preferences or keys.
### Compatibility 0.2.64 / firmware 0.2.55
Both board profiles, the Claude skill and the editor's alert tips; the app raises `FIRMWARE_VERSION`. Storage, tile
protocol, preferences and keys are unchanged.
- `alert_title` gets `height: ${ALERT_TITLE_H}`, one line of its `headline` font: 32 px on the Guition, 21 px on the CYD.
That is FreeType's size height of Roboto 500 at 27 and 18 px (`hhea` ascender minus descender, scaled and rounded),
which ESPHome 2026.6.2 hands to `lv_font_t.line_height`. LVGL 9.5's `lv_label_refr_text` only puts the dots of
`long_mode: DOT` on a label whose wrapped text is taller than the label, and a label without a height is as tall as
its wrapped text, so a long title wrapped and ran into `alert_subtitle` (y 68 / 44). With one line of height LVGL
breaks that line anywhere and ends it with "...". A title that fits draws the same pixels as before.
- `tests/test_alert.py` computes the line height from the font file and fails when `ALERT_TITLE_H` differs from it or
the title reaches the subtitle: a new `headline` size needs a new `ALERT_TITLE_H` on that board.
- Older firmware keeps wrapping a long title over the subtitle.
- `claude_skill.text()` and the alert tips in `static/index.html` said the CYD shows about forty characters of a title.
FreeType's advances of Roboto 500 give 20-23 characters before the "..." on the Guition (304 px at 27 px) and 24-25
on the CYD (212 px at 18 px), so both now say about twenty. An installed skill shows as outdated until it is
installed again.
### Compatibility 0.2.63 / firmware 0.2.54
Firmware (`components/smart_display`, new `theme.h`), both board profiles and the app's settings. The tile protocol
+73
View File
@@ -0,0 +1,73 @@
# Test results app 0.2.64 / firmware 0.2.55 (2026-09-17)
An alert with a long title (`show_alert` with title "Washing machine is done" and subtitle "Empty it before the laundry
smells") showed the title on two lines on the Guition, the second line on top of the subtitle, although the comment on
`ALERT_TITLE_MAX` and README_EXTENDED promise one line that ends in an ellipsis. See CHANGELOG 0.2.64 and the
compatibility note in docs/RELEASING.md. Built on 0.2.62 while Dark mode was under way in another session, rebased
onto 0.2.63 when it was released, and tested again there (only the version lines, CHANGELOG and RELEASING conflicted).
## Cause
LVGL 9.5.0 (`src/widgets/label/lv_label.c`, as ESPHome 2026.6.2 builds it): `lv_label_refr_text` only puts the dots of
`LV_LABEL_LONG_MODE_DOTS` on a label whose wrapped text is taller than its content area. `alert_title` had a width but
no height, so `LV_EVENT_GET_SELF_SIZE` made it exactly as tall as its wrapped text and the dots never came. With a
height of one line, `lv_label_get_letter_on` finds the letter on that line (which it breaks anywhere, since it is the
last one that shows) and the text ends in "...".
ESPHome's `lv_font_t.line_height` is FreeType's size height. The generated code builds `headline` (Roboto 500) as
`font::Font(..., 26, 32, 7, ...)` at 27 px and `font::Font(..., 17, 21, 5, ...)` at 18 px (baseline, height, descender),
and freetype-py (FreeType 2.13.2) gives the same 32 and 21 px.
## Automated
- Python (`.venv-portal`): 312 tests OK on 0.2.63 (302 on 0.2.62). New in `tests/test_alert.py`: in both profiles and
both packages the title has `height: ${ALERT_TITLE_H}` and the `headline` font, `ALERT_TITLE_H` equals the line
height computed from the font file (`head` and `hhea`, rounded as FreeType does: 32 and 21), and the title ends above
the subtitle. The test fails on 0.2.62 before the fix.
- C++ (`clang++ -std=c++17 -Wall -Wextra -Werror -I. -I components/smart_display`): 16/16 PASS on 0.2.63 (15/15 on
0.2.62).
- `generate_packages.py --check` green.
## Builds (ESPHome 2026.6.2)
Check profiles with the generated packages and local components, as for 0.2.62, built for this release and for 0.2.63
(the same check profiles from `bc2a754`). All compile without errors.
| Profile | RAM | Flash | vs 0.2.63 |
|---|---|---|---|
| CYD | 23.7% (77,500 B) | 77.4% (1,420,991 B) | +0 B RAM, -8 B flash |
| Guition | 24.7% (80,852 B) | 18.8% (1,524,647 B) | +0 B RAM, +8 B flash |
## Firmware on the Mac (host builds, driven over the ESPHome API)
`show_alert` on host builds of both packages over a demo page, before and after the change, with a probe that logs the
title's and subtitle's position and height as LVGL laid them out and the text the title shows:
| Board | Title | Before: title height, bottom | After |
|---|---|---|---|
| Guition | Washing machine is done | 64 px (2 lines), y 90, over the subtitle at y 68 | 32 px, y 58: "Washing machine is d..." |
| Guition | Door open | 32 px, y 58 | the same pixels |
| Guition | a title of 64 bytes | 96 px (3 lines), y 122 | 32 px, y 58: "The dishwasher in the ..." |
| CYD | Washing machine is done | 21 px (fits), y 37 | the same pixels |
| CYD | Door open | 21 px, y 37 | the same pixels |
| CYD | a title of 48 bytes | 42 px (2 lines), y 58, over the subtitle at y 44 | 21 px, y 37: "The dishwasher in the ki..." |
Comparing the renders pixel by pixel: no difference for the three titles that fit, and for the others only inside the
title and subtitle area. The "g" of "Washing" keeps its descender in the one-line box, which is as high as the label
of a title that fits always was.
On 0.2.63 (rebased, firmware reporting 0.2.55) the probe gives the same numbers and texts for all six, in the light
look and with the Dark mode switch on; the light card is pixel-identical to the render before the rebase, and the dark
card shows the same single line with its ellipsis.
## How long a title can be
The Claude skill and the editor's alert tips said the CYD shows about forty characters of a title. Measured with
FreeType's advances of Roboto 500 at the label widths, on seven typical titles: 20-23 characters before the "..." on
the Guition, 24-25 on the CYD, which matches the cuts LVGL made in the renders above ("Washing machine is d" and "The
dishwasher in the ki"). Both now say about twenty. The editor, served from this tree on a demo home, shows the new tip
in Settings → Alerts → Tips without console errors, and `claude_skill.text()` carries the new sentence.
## Not tested
- Physical screens: not flashed in this round.
+3 -1
View File
@@ -2,7 +2,7 @@
# Source profile for packages/guition.yaml (tools/generate_packages.py); see docs/GUITION.md.
# A screen gets its tiles from the ESP Screen Manager add-on while it runs. The CYD is its own profile.
substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.54"
SCREEN_FIRMWARE_VERSION: "0.2.55"
# 0: no drift limit; LVGL decides, releasing inside the tile is a tap, leaving it is not
# (like iOS). The CYD keeps a limit because its resistive panel jumps.
TOUCH_MOVE_LIMIT_PX: "0"
@@ -60,6 +60,7 @@ substitutions:
ALERT_TEXT_X: "90"
ALERT_TEXT_W: "304"
ALERT_TITLE_Y: "26"
ALERT_TITLE_H: "32" # one line of headline; a DOT label only ends in an ellipsis with a height
ALERT_SUBTITLE_Y: "68"
ALERT_SUBTITLE_H: "150"
ALERT_BUTTON_W: "150"
@@ -1524,6 +1525,7 @@ lvgl:
x: ${ALERT_TEXT_X}
y: ${ALERT_TITLE_Y}
width: ${ALERT_TEXT_W}
height: ${ALERT_TITLE_H}
long_mode: DOT
text: "Notification"
text_font: headline
+3 -1
View File
@@ -17,7 +17,7 @@
############################################################
substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.54"
SCREEN_FIRMWARE_VERSION: "0.2.55"
# A tap survives a finger that drifts up to this far (320 px over 58 mm: 1 cm); further is a swipe.
TOUCH_MOVE_LIMIT_PX: "56"
# Shortest contact that counts as a tap; a resistive panel bounces, a capacitive one does not.
@@ -94,6 +94,7 @@ substitutions:
ALERT_TEXT_X: "62"
ALERT_TEXT_W: "212"
ALERT_TITLE_Y: "16"
ALERT_TITLE_H: "21" # one line of headline; a DOT label only ends in an ellipsis with a height
ALERT_SUBTITLE_Y: "44"
ALERT_SUBTITLE_H: "88"
ALERT_BUTTON_W: "100"
@@ -1621,6 +1622,7 @@ lvgl:
x: ${ALERT_TEXT_X}
y: ${ALERT_TITLE_Y}
width: ${ALERT_TEXT_W}
height: ${ALERT_TITLE_H}
long_mode: DOT
text: "Notification"
text_font: headline
+3 -1
View File
@@ -18,7 +18,7 @@
############################################################
substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.54"
SCREEN_FIRMWARE_VERSION: "0.2.55"
# A tap survives a finger that drifts up to this far (320 px over 58 mm: 1 cm); further is a swipe.
TOUCH_MOVE_LIMIT_PX: "56"
# Shortest contact that counts as a tap; a resistive panel bounces, a capacitive one does not.
@@ -95,6 +95,7 @@ substitutions:
ALERT_TEXT_X: "62"
ALERT_TEXT_W: "212"
ALERT_TITLE_Y: "16"
ALERT_TITLE_H: "21" # one line of headline; a DOT label only ends in an ellipsis with a height
ALERT_SUBTITLE_Y: "44"
ALERT_SUBTITLE_H: "88"
ALERT_BUTTON_W: "100"
@@ -1610,6 +1611,7 @@ lvgl:
x: ${ALERT_TEXT_X}
y: ${ALERT_TITLE_Y}
width: ${ALERT_TEXT_W}
height: ${ALERT_TITLE_H}
long_mode: DOT
text: "Notification"
text_font: headline
+3 -1
View File
@@ -3,7 +3,7 @@
# Source profile for packages/guition.yaml (tools/generate_packages.py); see docs/GUITION.md.
# A screen gets its tiles from the ESP Screen Manager add-on while it runs. The CYD is its own profile.
substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.54"
SCREEN_FIRMWARE_VERSION: "0.2.55"
# 0: no drift limit; LVGL decides, releasing inside the tile is a tap, leaving it is not
# (like iOS). The CYD keeps a limit because its resistive panel jumps.
TOUCH_MOVE_LIMIT_PX: "0"
@@ -61,6 +61,7 @@ substitutions:
ALERT_TEXT_X: "90"
ALERT_TEXT_W: "304"
ALERT_TITLE_Y: "26"
ALERT_TITLE_H: "32" # one line of headline; a DOT label only ends in an ellipsis with a height
ALERT_SUBTITLE_Y: "68"
ALERT_SUBTITLE_H: "150"
ALERT_BUTTON_W: "150"
@@ -1513,6 +1514,7 @@ lvgl:
x: ${ALERT_TEXT_X}
y: ${ALERT_TITLE_Y}
width: ${ALERT_TEXT_W}
height: ${ALERT_TITLE_H}
long_mode: DOT
text: "Notification"
text_font: headline
+8
View File
@@ -1,3 +1,11 @@
## 0.2.64 (firmware 0.2.55)
A long alert title stays on its own line.
- **An alert's title no longer runs into the text below it.** A title too long for one line, such as "Washing machine is done" on a Guition, wrapped onto a second line on top of the subtitle. It now ends in an ellipsis on one line, as the alert documentation says: "Washing machine is d...". A title that fits looks exactly as before; put the details in the subtitle.
- **Claude knows how long a title can be.** The Claude skill and the alert tips in ESP Screens said the CYD shows about forty characters of a title; both screens show about twenty. Install the skill again from Settings → Claude to get it.
- Needs firmware 0.2.55: press **Update** on the screen. Includes everything from 0.2.63.
## 0.2.63 (firmware 0.2.54)
Dark mode, for a screen beside the bed.
+1 -1
View File
@@ -181,7 +181,7 @@ actions:
|---|---|---|---|
{fields}
Text limits are in bytes; an accented letter takes two. Keep the title short, the CYD shows about forty characters on one line, and put details in the subtitle.
Text limits are in bytes; an accented letter takes two. Keep the title short: a screen shows about twenty characters of it on one line and ends a longer title with an ellipsis. Put details in the subtitle.
## Colors
+1 -1
View File
@@ -17,7 +17,7 @@ WEEKDAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
REPO = 'https://github.com/MaxGramser/homeassistant_espscreen'
REFS = {'cyd': 'main', 'guition': 'main'}
# Firmware shipped with this app release; screens below it get an update offer.
FIRMWARE_VERSION = '0.2.54'
FIRMWARE_VERSION = '0.2.55'
# The Auto standby switch a screen offers Home Assistant automations.
AUTO_STANDBY_MIN_FIRMWARE = '0.2.41'
# The settings page the screen opens itself, and the screen.settings tile that opens it.
+2 -2
View File
@@ -570,8 +570,8 @@
<ul class="alerts-list">
<li>Use a <b>color per kind of notification</b>: orange for the door, red for
smoke or water, green for "done". The text always stays dark and readable.</li>
<li>Keep the title <b>short</b>; the CYD shows about forty characters on one line.
Put details in the subtitle.</li>
<li>Keep the title <b>short</b>; a screen shows about twenty characters on one line and
ends a longer title with an ellipsis. Put details in the subtitle.</li>
<li>A <b>custom button text</b> turns the notification into a question: "Coming", "Seen",
"Turn off". The event then tells you who responded.</li>
<li>For an alert that resolves itself (the door opens, the laundry gets taken out)
+1 -1
View File
@@ -1,5 +1,5 @@
name: ESP Screen Manager
version: "0.2.63"
version: "0.2.64"
slug: esp_screen_manager
description: Choose tiles for your CYD and Guition touchscreen, without reflashing.
url: https://github.com/MaxGramser/homeassistant_espscreen
+28
View File
@@ -1,5 +1,6 @@
"""The alert a Home Assistant action puts over the whole screen: both board profiles and both remote packages."""
import re
import struct
import sys
import unittest
from pathlib import Path
@@ -19,6 +20,20 @@ def script(text, name):
"""One script block without its trailing comment lines, which introduce the next script."""
return re.sub(r'^\s*#.*\n?', '', section(text, f' - id: {name}\n', '\n - id: '), flags=re.M)
def line_height(font, size):
"""LVGL's line height of an ESPHome font: FreeType's size height ('hhea' ascender - descender + line gap, scaled
to the pixel size and rounded to whole pixels), which ESPHome hands to lv_font_t.line_height."""
data = font.read_bytes()
tables = {}
for i in range(struct.unpack('>H', data[4:6])[0]):
tag, _, offset, _ = struct.unpack('>4sIII', data[12 + 16 * i:28 + 16 * i])
tables[tag] = offset
units_per_em = struct.unpack('>H', data[tables[b'head'] + 18:tables[b'head'] + 20])[0]
ascender, descender, line_gap = struct.unpack('>hhh', data[tables[b'hhea'] + 4:tables[b'hhea'] + 10])
scale = ((size * 64) << 16) // units_per_em # 16.16
height = ((ascender - descender + line_gap) * scale + 0x8000) >> 16 # 26.6 pixels
return (height + 32) // 64
class AlertTests(unittest.TestCase):
def sources(self):
for name in list(PROFILES.values()) + list(PACKAGES.values()):
@@ -52,6 +67,19 @@ class AlertTests(unittest.TestCase):
self.assertLess(ok.index('touch_guard.accept'), ok.index('id: alert_dismiss'), name)
self.assertIn('reason: "ok"', ok, name)
def test_title_is_one_line_high_so_a_long_title_ends_in_an_ellipsis(self):
# LVGL 9.5 only puts the ellipsis on a DOT label whose wrapped text is taller than the label. Without a height
# the label grows with its lines instead, and a long title ran into the subtitle.
for name, text in self.sources():
top = section(section(text, '\nlvgl:\n', '\nscript:\n'), ' top_layer:\n', ' pages:\n')
title = section(top, 'id: alert_title\n', 'id: alert_subtitle')
self.assertIn('height: ${ALERT_TITLE_H}\n', title, name)
self.assertIn('text_font: headline\n', title, name)
font, size = re.search(r'(?m)^ - file: "(?:[^"\n]*/)?(fonts/[^"\n]+)"\n id: headline\n size: (\d+)$', text).groups()
v = lambda key: int(re.search(rf'^ {key}: "(-?\d+)"', text, re.M)[1])
self.assertEqual(v('ALERT_TITLE_H'), line_height(ROOT / font, int(size)), name)
self.assertLessEqual(v('ALERT_TITLE_Y') + v('ALERT_TITLE_H'), v('ALERT_SUBTITLE_Y'), name)
def test_standby_waits_and_no_other_path_closes_the_card(self):
for name, text in self.sources():
guard = re.search(r'if \(id\(display_dimmed\) \|\| id\(touch_down\) \|\| id\(calibration_active\)([^\n]*)\) return false;', text)