Add a per-widget text-size picker for calendar/tasks legibility
Build and push server image / test (push) Successful in 42s
Build and push server image / build-and-push (push) Successful in 3m33s
Build and push server image / deploy (push) Failing after 1m24s

Calendar and tasks pack the most body text at the smallest default
sizes, so those two gear-icon dialogs get a "Text size" card (Normal/
Large/X-Large) alongside the existing Border card -- a new Widget-level
font_scale column with its own POST .../font-scale endpoint, same
Widget-property-not-config-field shape as border_style. Threaded through
every classic (calendar_render.py) and modern (html_render.py/
calendar_html_render.py) size calc via one shared panel_style.
scaled_size() so row heights/max_rows already derived from font size
re-fit around the bigger text automatically.
This commit is contained in:
2026-08-02 03:23:27 +00:00
parent 09119e775f
commit 2868087467
22 changed files with 426 additions and 60 deletions
+14
View File
@@ -42,6 +42,20 @@ a button press does.
`api_widget_config_save`) since that endpoint's per-type dispatch is
keyed on a config row via `widget_locked`, and border fields live on
`Widget` itself, not any per-type config table.
Also carries `font_scale` (one of `panel_style.FONT_SCALE_CHOICES` --
`1.0`/`1.25`/`1.5`, labeled Normal/Large/X-Large), a per-widget
legibility control: calendar and tasks widgets pack in the most body
text at the smallest default sizes, so their gear-icon dialogs get a
"Text size" card (`_widget_font_scale_fields.html`) the other types
don't. Same Widget-level-property-not-config-field reasoning as
border, and its own `POST .../widgets/{id}/font-scale` endpoint for
the same reason. `panel_style.scaled_size(value, font_scale)` is the
one shared multiply-and-round point every classic (`calendar_render.py`)
and modern (`html_render.py`/`calendar_html_render.py`) size calc
routes through immediately after its own tier lookup/floor, so row
heights and per-view row caps (already derived from the font size, not
a fixed constant) automatically re-fit around the bigger text instead
of overflowing their box.
- Per-type 1:1 extension tables -- `PhotoWidgetConfig`,
`CalendarWidgetConfig`, `WhiteboardWidgetConfig`, `TaskWidgetConfig`,
`StaticWidgetConfig`, `TextWidgetConfig`, `WeatherWidgetConfig`,
+21 -18
View File
@@ -71,7 +71,8 @@ def _day_section_data(day: date, events: list[dict], tz: ZoneInfo, palette_rgb,
def build_agenda(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
palette_rgb: list | None = None, weather_cities: list[dict] | None = None,
weather_units: str = "fahrenheit", theme_name: str | None = None) -> Image.Image:
weather_units: str = "fahrenheit", theme_name: str | None = None,
font_scale: float = 1.0) -> Image.Image:
"""HTML/CSS-rendered analogue of calendar_render._build_agenda.
Bold-minimal: no card/border/shadow (theme["radius"]/theme["shadow"]
@@ -83,8 +84,8 @@ def build_agenda(events: list[dict], browse_offset: int, target_w: int, target_h
improvement over the old design, not just a visual one."""
theme = theme_tokens.resolve_theme(theme_name, "calendar", palette_rgb)
day = datetime.now(tz).date() + timedelta(days=browse_offset)
title_size = max(14, min(target_w, target_h) // 12)
body_size = max(11, min(target_w, target_h) // 20)
title_size = panel_style.scaled_size(max(14, min(target_w, target_h) // 12), font_scale)
body_size = panel_style.scaled_size(max(11, min(target_w, target_h) // 20), font_scale)
weather_size = max(10, body_size - 2)
row_h = body_size + 14
unit_suffix = "F" if weather_units == "fahrenheit" else "C"
@@ -120,7 +121,8 @@ def build_agenda(events: list[dict], browse_offset: int, target_w: int, target_h
def build_today_tomorrow(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
palette_rgb: list | None = None, weather_cities: list[dict] | None = None,
weather_units: str = "fahrenheit", theme_name: str | None = None) -> Image.Image:
weather_units: str = "fahrenheit", theme_name: str | None = None,
font_scale: float = 1.0) -> Image.Image:
"""HTML/CSS-rendered analogue of calendar_render._build_today_tomorrow
-- two day-sections stacked (see _day_section_data). Bold-minimal, no
card (see build_agenda's docstring) -- each section's own slim accent
@@ -128,8 +130,8 @@ def build_today_tomorrow(events: list[dict], browse_offset: int, target_w: int,
theme = theme_tokens.resolve_theme(theme_name, "calendar", palette_rgb)
start_day = datetime.now(tz).date() + timedelta(days=browse_offset)
section_h = target_h // 2
title_size = max(13, section_h // 8)
body_size = max(10, min(target_w, target_h) // 26)
title_size = panel_style.scaled_size(max(13, section_h // 8), font_scale)
body_size = panel_style.scaled_size(max(10, min(target_w, target_h) // 26), font_scale)
weather_size = max(9, body_size - 2)
row_h = body_size + 12
unit_suffix = "F" if weather_units == "fahrenheit" else "C"
@@ -170,7 +172,7 @@ def build_today_tomorrow(events: list[dict], browse_offset: int, target_w: int,
def build_week(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
week_start: int, palette_rgb: list | None = None, weather_cities: list[dict] | None = None,
weather_units: str = "fahrenheit", days: int = 7, layout: str = "horizontal",
start_offset: int = 0, theme_name: str | None = None) -> Image.Image:
start_offset: int = 0, theme_name: str | None = None, font_scale: float = 1.0) -> Image.Image:
"""HTML/CSS-rendered analogue of calendar_render._build_week -- both
the vertical (stacked day-sections, reusing build_today_tomorrow's
template with an arbitrary day count) and horizontal (side-by-side
@@ -189,8 +191,8 @@ def build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
if layout == "vertical":
section_h = target_h // days
title_size = max(11, min(20, section_h // 6))
body_size = max(9, min(target_w, target_h) // (18 + days))
title_size = panel_style.scaled_size(max(11, min(20, section_h // 6)), font_scale)
body_size = panel_style.scaled_size(max(9, min(target_w, target_h) // (18 + days)), font_scale)
weather_size = max(8, body_size - 2)
row_h = body_size + 10
accent_h = round(html_render._clamp(min(target_w, target_h) * 0.018, 3, 5))
@@ -221,7 +223,7 @@ def build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
]
return html_render.ordered_dither_regions(rendered, palette_rgb, accent_regions=accent_regions)
header_size = max(10, min(16, (target_w // days) // 6))
header_size = panel_style.scaled_size(max(10, min(16, (target_w // days) // 6)), font_scale)
chip_size = max(9, header_size - 3)
weather_size = max(8, chip_size - 1)
col_w = max(1, (target_w - panel_style.GUTTER * 2) // days)
@@ -265,7 +267,8 @@ def build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
def build_month(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
week_start: int, palette_rgb: list | None = None, theme_name: str | None = None) -> Image.Image:
week_start: int, palette_rgb: list | None = None, theme_name: str | None = None,
font_scale: float = 1.0) -> Image.Image:
"""HTML/CSS-rendered analogue of calendar_render._build_month --
density dots per day, not literal event text, same reasoning as the
classic renderer (real text at typical month-cell size is close to
@@ -289,8 +292,8 @@ def build_month(events: list[dict], browse_offset: int, target_w: int, target_h:
)
day_names = [n[:3] for n in (WEEKDAY_NAMES[week_start:] + WEEKDAY_NAMES[:week_start])]
header_size = max(11, min(16, target_h // 30))
day_size = max(10, min(15, target_w // 55))
header_size = panel_style.scaled_size(max(11, min(16, target_h // 30)), font_scale)
day_size = panel_style.scaled_size(max(10, min(15, target_w // 55)), font_scale)
dot_size = max(4, day_size // 2)
accent_h = round(html_render._clamp(min(target_w, target_h) * 0.02, 3, 6))
@@ -323,7 +326,7 @@ def build_month(events: list[dict], browse_offset: int, target_w: int, target_h:
def build(events: list[dict], view: str, browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
week_start: int, palette_rgb: list | None = None, weather_cities: list[dict] | None = None,
weather_units: str = "fahrenheit", week_days: int = 7, week_layout: str = "horizontal",
week_start_offset: int = 0, theme_name: str | None = None) -> Image.Image:
week_start_offset: int = 0, theme_name: str | None = None, font_scale: float = 1.0) -> Image.Image:
"""Dispatches to the right build_* -- mirrors calendar_render._build's
exact "month falls back to agenda when it doesn't fit" resolution, so
a narrow month-mode widget set to modern style still gets a sensible
@@ -334,11 +337,11 @@ def build(events: list[dict], view: str, browse_offset: int, target_w: int, targ
if effective_view == "agenda":
return build_agenda(events, browse_offset, target_w, target_h, tz, palette_rgb, weather_cities,
weather_units, theme_name)
weather_units, theme_name, font_scale)
if effective_view == "today_tomorrow":
return build_today_tomorrow(events, browse_offset, target_w, target_h, tz, palette_rgb, weather_cities,
weather_units, theme_name)
weather_units, theme_name, font_scale)
if effective_view == "week":
return build_week(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb, weather_cities,
weather_units, week_days, week_layout, week_start_offset, theme_name)
return build_month(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb, theme_name)
weather_units, week_days, week_layout, week_start_offset, theme_name, font_scale)
return build_month(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb, theme_name, font_scale)
+33 -23
View File
@@ -512,10 +512,12 @@ _AGENDA_FONTS = {"large": (34, 22, 20), "medium": (24, 22, 16), "small": (18, 16
def _build_agenda(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
palette_rgb: list | None = None, weather_cities: list[dict] | None = None,
weather_units: str = "fahrenheit") -> Image.Image:
weather_units: str = "fahrenheit", font_scale: float = 1.0) -> Image.Image:
img, draw, region = panel_style.card_canvas(target_w, target_h)
title_size, body_size, weather_size = _AGENDA_FONTS[_size_tier(target_w, target_h)]
title_size, body_size, weather_size = (
panel_style.scaled_size(v, font_scale) for v in _AGENDA_FONTS[_size_tier(target_w, target_h)]
)
title_font = panel_style.font_bold(title_size)
body_font = panel_style.font_regular(body_size)
weather_font = panel_style.font_regular(weather_size)
@@ -533,7 +535,7 @@ _TODAY_TOMORROW_FONTS = {"large": (26, 18, 16), "medium": (20, 15, 13), "small":
def _build_today_tomorrow(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
palette_rgb: list | None = None, weather_cities: list[dict] | None = None,
weather_units: str = "fahrenheit") -> Image.Image:
weather_units: str = "fahrenheit", font_scale: float = 1.0) -> Image.Image:
"""Two _draw_agenda_day sections stacked vertically (below each other
rather than side-by-side -- narrower than tall doesn't leave enough
width per day for the event-row text at smaller sizes). browse_offset
@@ -542,7 +544,9 @@ def _build_today_tomorrow(events: list[dict], browse_offset: int, target_w: int,
both views."""
img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h)
title_size, body_size, weather_size = _TODAY_TOMORROW_FONTS[_size_tier(target_w, target_h)]
title_size, body_size, weather_size = (
panel_style.scaled_size(v, font_scale) for v in _TODAY_TOMORROW_FONTS[_size_tier(target_w, target_h)]
)
title_font = panel_style.font_bold(title_size)
body_font = panel_style.font_regular(body_size)
weather_font = panel_style.font_regular(weather_size)
@@ -572,7 +576,7 @@ def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
week_start: int, palette_rgb: list | None = None,
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
days: int = 7, layout: str = "horizontal",
start_offset: int = 0) -> Image.Image:
start_offset: int = 0, font_scale: float = 1.0) -> Image.Image:
"""`days` (2-10, see routers/api_widgets.py's clamp) side-by-side
columns (layout="horizontal", the original fixed-at-7 behavior
generalized) or stacked bands (layout="vertical", reusing
@@ -597,9 +601,9 @@ def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
if layout == "vertical":
title_base, body_base, weather_base = _WEEK_VERTICAL_FONTS[tier]
title_font = panel_style.font_bold(max(14, title_base - days))
body_font = panel_style.font_regular(max(11, body_base - days))
weather_font = panel_style.font_regular(max(9, weather_base - days))
title_font = panel_style.font_bold(panel_style.scaled_size(max(14, title_base - days), font_scale))
body_font = panel_style.font_regular(panel_style.scaled_size(max(11, body_base - days), font_scale))
weather_font = panel_style.font_regular(panel_style.scaled_size(max(9, weather_base - days), font_scale))
section_h = ch // days
for i in range(days):
section_y0 = cy0 + i * section_h
@@ -611,7 +615,9 @@ def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
weather_cities, weather_font, weather_units)
return img
header_size, chip_size, weather_size = _WEEK_HORIZONTAL_FONTS[tier]
header_size, chip_size, weather_size = (
panel_style.scaled_size(v, font_scale) for v in _WEEK_HORIZONTAL_FONTS[tier]
)
header_font = panel_style.font_bold(header_size)
chip_font = panel_style.font_regular(chip_size)
weather_font = panel_style.font_regular(weather_size)
@@ -667,7 +673,7 @@ _MONTH_FONTS = {"large": (16, 18), "medium": (12, 13), "small": (12, 13)}
def _build_month(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
week_start: int, palette_rgb: list | None = None) -> Image.Image:
week_start: int, palette_rgb: list | None = None, font_scale: float = 1.0) -> Image.Image:
"""Density dots per day, not literal event text -- real text at
typical month-cell size (~100x70px) is close to unreadable on a
6-color dithered e-ink panel. Capped at 4 visible dots, "+N" beyond.
@@ -677,7 +683,9 @@ def _build_month(events: list[dict], browse_offset: int, target_w: int, target_h
comment above MARGIN/BG/FG."""
img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h)
header_size, day_size = _MONTH_FONTS[_size_tier(target_w, target_h)]
header_size, day_size = (
panel_style.scaled_size(v, font_scale) for v in _MONTH_FONTS[_size_tier(target_w, target_h)]
)
header_font = panel_style.font_bold(header_size)
day_font_in_month = panel_style.font_bold(day_size)
day_font_out_of_month = panel_style.font_regular(day_size)
@@ -750,7 +758,7 @@ def _build(events: list[dict], view: str, browse_offset: int, target_w: int, tar
fetch_summary: str, week_start: int, palette_rgb: list | None = None,
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
week_days: int = 7, week_layout: str = "horizontal",
week_start_offset: int = 0) -> Image.Image:
week_start_offset: int = 0, font_scale: float = 1.0) -> Image.Image:
tz = ZoneInfo(timezone) if timezone else ZoneInfo("UTC")
effective_view = view
if view == "month" and not _month_view_fits(target_w, target_h):
@@ -758,13 +766,13 @@ def _build(events: list[dict], view: str, browse_offset: int, target_w: int, tar
if effective_view == "agenda":
img = _build_agenda(events, browse_offset, target_w, target_h, tz, palette_rgb,
weather_cities, weather_units)
weather_cities, weather_units, font_scale)
elif effective_view == "today_tomorrow":
img = _build_today_tomorrow(events, browse_offset, target_w, target_h, tz, palette_rgb,
weather_cities, weather_units)
weather_cities, weather_units, font_scale)
elif effective_view == "week":
img = _build_week(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb,
weather_cities, weather_units, week_days, week_layout, week_start_offset)
weather_cities, weather_units, week_days, week_layout, week_start_offset, font_scale)
elif effective_view == "month":
# Never given weather -- no room for it at typical month-cell
# size, same reasoning that already keeps this view to density
@@ -772,10 +780,10 @@ def _build(events: list[dict], view: str, browse_offset: int, target_w: int, tar
# docstring). Colors are still passed through, though -- that's
# a different concern (legibility of individual events) than
# needing a whole extra strip of content.
img = _build_month(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb)
img = _build_month(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb, font_scale)
else:
img = _build_agenda(events, browse_offset, target_w, target_h, tz, palette_rgb,
weather_cities, weather_units)
weather_cities, weather_units, font_scale)
if fetch_summary:
# Drawn as a final overlay onto the already-composited img (not
@@ -814,13 +822,13 @@ def render_calendar_preview_png(events: list[dict], view: str, browse_offset: in
fetch_summary: str = "", manage: dict | None = None, week_start: int = 0,
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
week_days: int = 7, week_layout: str = "horizontal",
week_start_offset: int = 0) -> bytes:
week_start_offset: int = 0, font_scale: float = 1.0) -> bytes:
"""Same pipeline as render_calendar, but a normal browser-viewable
PNG in logical (upright) orientation -- mirrors
image_pipeline.render_preview_png's relationship to render_frame."""
target_w, target_h = logical_render_size(orientation)
img = _build(events, view, browse_offset, target_w, target_h, timezone, fetch_summary, week_start,
palette_rgb, weather_cities, weather_units, week_days, week_layout, week_start_offset)
palette_rgb, weather_cities, weather_units, week_days, week_layout, week_start_offset, font_scale)
img = _apply_manage_overlay(img, manage)
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
buf = io.BytesIO()
@@ -835,12 +843,14 @@ _TASKS_FONTS = {"large": (24, 18), "medium": (20, 16), "small": (16, 13)}
def _build_tasks(tasks: list[dict], target_w: int, target_h: int, palette_rgb: list | None = None,
title: str = "Tasks") -> Image.Image:
title: str = "Tasks", font_scale: float = 1.0) -> Image.Image:
"""A tasks widget's entire region is the checklist -- unlike the old
week-view slot, there's no day columns/header to share space with,
so this is just _draw_tasks over the whole box."""
img, draw, region = panel_style.card_canvas(target_w, target_h)
title_size, body_size = _TASKS_FONTS[_size_tier(target_w, target_h)]
title_size, body_size = (
panel_style.scaled_size(v, font_scale) for v in _TASKS_FONTS[_size_tier(target_w, target_h)]
)
title_font = panel_style.font_bold(title_size)
body_font = panel_style.font_regular(body_size)
_draw_tasks(img, draw, region, tasks, title_font, body_font, palette_rgb, title)
@@ -860,12 +870,12 @@ def render_tasks(tasks: list[dict], orientation: str, palette_rgb: list | None,
def render_tasks_preview_png(tasks: list[dict], orientation: str, palette_rgb: list | None,
manage: dict | None = None, title: str = "Tasks") -> bytes:
manage: dict | None = None, title: str = "Tasks", font_scale: float = 1.0) -> bytes:
"""Same pipeline as render_tasks, but a normal browser-viewable PNG
in logical (upright) orientation -- mirrors render_calendar_preview_
png's relationship to render_calendar."""
target_w, target_h = logical_render_size(orientation)
img = _build_tasks(tasks, target_w, target_h, palette_rgb, title)
img = _build_tasks(tasks, target_w, target_h, palette_rgb, title, font_scale=font_scale)
img = _apply_manage_overlay(img, manage)
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
buf = io.BytesIO()
+3 -3
View File
@@ -579,7 +579,7 @@ def build_text(cfg, target_w: int, target_h: int, palette_rgb: list | None = Non
# --- Tasks "modern" style ---------------------------------------------------
def build_tasks(tasks: list[dict], target_w: int, target_h: int, palette_rgb: list | None = None,
title: str = "Tasks", theme_name: str | None = None) -> Image.Image:
title: str = "Tasks", theme_name: str | None = None, font_scale: float = 1.0) -> Image.Image:
"""HTML/CSS-rendered analogue of calendar_render._build_tasks --
same header+checklist shape. Reuses calendar_render's own
_event_colors/_fmt_task_due (the exact color-dedup/due-date-format
@@ -602,8 +602,8 @@ def build_tasks(tasks: list[dict], target_w: int, target_h: int, palette_rgb: li
theme = theme_tokens.resolve_theme(theme_name, "tasks", palette_rgb)
base = min(target_w, target_h)
accent_h = round(_clamp(base * 0.025, 3, 6))
title_size = max(14, base // 12)
body_size = max(11, base // 20)
title_size = panel_style.scaled_size(max(14, base // 12), font_scale)
body_size = panel_style.scaled_size(max(11, base // 20), font_scale)
row_h = body_size + 14
box_size = max(10, body_size - 4)
header_h = accent_h + 6 + title_size
+14
View File
@@ -889,6 +889,19 @@ def _migration_39(conn) -> None:
conn.execute(text("ALTER TABLE frames ADD COLUMN theme TEXT NOT NULL DEFAULT 'classic'"))
def _migration_40(conn) -> None:
"""Per-widget text-size multiplier (models.Widget.font_scale, see
panel_style.FONT_SCALE_CHOICES) -- a Widget-level column like
border_style/border_thickness/border_color_index, not a per-type
config field, since any widget type with body text can use it. Every
existing widget defaults to 1.0 (unchanged size) until its own
dialog's "Text size" picker sets it. Guarded per-column, same
reasoning as every prior migration's own comment."""
existing = {c["name"] for c in inspect(conn).get_columns("widgets")}
if "font_scale" not in existing:
conn.execute(text("ALTER TABLE widgets ADD COLUMN font_scale REAL NOT NULL DEFAULT 1.0"))
MIGRATIONS = [
(1, _migration_1),
(2, _migration_2),
@@ -929,6 +942,7 @@ MIGRATIONS = [
(37, _migration_37),
(38, _migration_38),
(39, _migration_39),
(40, _migration_40),
]
+6
View File
@@ -499,6 +499,12 @@ class Widget(Base):
border_style: Mapped[str] = mapped_column(String, default="none")
border_thickness: Mapped[int] = mapped_column(Integer, default=3)
border_color_index: Mapped[int] = mapped_column(Integer, default=0)
# Text-size multiplier for this widget's own body/title text -- another
# Widget-level property regardless of widget_type, same reasoning as
# border_style above (any widget type with text can use it). One of
# panel_style.FONT_SCALE_CHOICES; 1.0 (unchanged size) for every
# existing widget until its dialog's "Text size" picker sets it.
font_scale: Mapped[float] = mapped_column(Float, default=1.0)
__table_args__ = (Index("ix_widgets_frame", "frame_id"),)
+22
View File
@@ -36,6 +36,28 @@ CONTENT_MARGIN = 20
CARD_RADIUS = 12
CHIP_RADIUS = 4
# models.Widget.font_scale's allowed values -- a per-widget text-size
# multiplier (the Layout dialog's "Text size" picker), same "reject
# invalid, don't silently coerce" posture as border_style. Deliberately a
# small fixed set (a <select>, not a raw slider) rather than an arbitrary
# float: every _AGENDA_FONTS/_TASKS_FONTS-style tuple in calendar_render.py
# and every proportional size in html_render.py/calendar_html_render.py
# derives row heights/max_rows from the same scaled font size, so an
# unbounded scale risks a layout that no longer fits its own box.
FONT_SCALE_CHOICES = (1.0, 1.25, 1.5)
def scaled_size(value: float, font_scale: float) -> int:
"""Applies a widget's font_scale to a text-size value and rounds to
an int px, floored at 1 so an extreme scale can never zero out a
font. Shared by both the classic (PIL, calendar_render.py) and modern
(HTML/CSS, html_render.py/calendar_html_render.py) renderers so "make
this widget's text bigger" behaves identically regardless of
render_style -- every caller applies this immediately after its own
tier lookup/floor calc, so row heights/max_rows computed from the
result already account for the bigger text."""
return max(1, round(value * font_scale))
# Index constants into DEFAULT_PALETTE_RGB/a frame's own Frame.
# palette_rgb override -- same order as image_pipeline.PALETTE_LABELS.
BLACK, WHITE, YELLOW, RED, BLUE, GREEN = range(6)
+33 -6
View File
@@ -26,7 +26,7 @@ from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from .. import calendar_render, grid, photo_queue, quiet_hours, weather, weather_render, webdav_client
from .. import calendar_render, grid, panel_style, photo_queue, quiet_hours, weather, weather_render, webdav_client
from ..auth import require_frame_control, require_frame_view, require_user_api
from ..db import frame_locked, get_db, widget_locked
from ..image_pipeline import (
@@ -96,7 +96,7 @@ def _widget_dict(w: Widget, locked: bool = False) -> dict:
return {"id": w.id, "widget_type": w.widget_type, "x": w.x, "y": w.y, "w": w.w, "h": w.h,
"sort_order": w.sort_order, "border_style": w.border_style,
"border_thickness": w.border_thickness, "border_color_index": w.border_color_index,
"locked": locked}
"font_scale": w.font_scale, "locked": locked}
def require_widget_view(
@@ -280,6 +280,31 @@ def api_widget_border(
return _widget_dict(widget)
class WidgetFontScaleRequest(BaseModel):
font_scale: float
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/font-scale")
def api_widget_font_scale(
body: WidgetFontScaleRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
db: Session = Depends(get_db),
):
"""Sets this widget's text-size multiplier (the Layout dialog's "Text
size" picker) -- a shared Widget-level property (see models.Widget.
font_scale), not a per-type config field, since any widget type with
body text can use it. Its own endpoint for the same reason
api_widget_border has one: api_widget_config_save's per-type dispatch
edits a config row via widget_locked, and font_scale lives on Widget
itself, not any per-type config table."""
frame, widget = frame_widget
if body.font_scale not in panel_style.FONT_SCALE_CHOICES:
raise HTTPException(400, f"font_scale must be one of {panel_style.FONT_SCALE_CHOICES}")
with frame_locked(db, frame.id):
widget.font_scale = body.font_scale
db.commit()
return _widget_dict(widget)
@router.delete("/api/frames/{frame_id}/widgets/{widget_id}")
def api_widget_delete(
widget_id: int, frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db)
@@ -876,7 +901,7 @@ def api_widget_preview_calendar(
img = calendar_html_render.build(
events, ccfg.view, ccfg.browse_offset, target_w, target_h, tz, ccfg.week_start, frame.palette_rgb,
weather_cities, ccfg.weather_units, ccfg.week_days, ccfg.week_layout, ccfg.week_start_offset,
frame.theme,
frame.theme, widget.font_scale,
)
quantized = _quantize(img, frame.palette_rgb, dither_strength=1.0)
png = _png_bytes(quantized)
@@ -887,7 +912,7 @@ def api_widget_preview_calendar(
week_start=ccfg.week_start,
weather_cities=weather_cities, weather_units=ccfg.weather_units,
week_days=ccfg.week_days, week_layout=ccfg.week_layout,
week_start_offset=ccfg.week_start_offset,
week_start_offset=ccfg.week_start_offset, font_scale=widget.font_scale,
)
return Response(content=png, media_type="image/png")
@@ -912,12 +937,14 @@ def api_widget_preview_tasks(
from .. import html_render
target_w, target_h = logical_render_size(frame.orientation)
img = html_render.build_tasks(tasks, target_w, target_h, frame.palette_rgb, title, frame.theme)
img = html_render.build_tasks(tasks, target_w, target_h, frame.palette_rgb, title, frame.theme,
widget.font_scale)
quantized = _quantize(img, frame.palette_rgb, dither_strength=1.0)
png = _png_bytes(quantized)
else:
png = calendar_render.render_tasks_preview_png(tasks, orientation=frame.orientation,
palette_rgb=frame.palette_rgb, title=title)
palette_rgb=frame.palette_rgb, title=title,
font_scale=widget.font_scale)
return Response(content=png, media_type="image/png")
+13 -3
View File
@@ -17,7 +17,7 @@ from fastapi.templating import Jinja2Templates
from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import theme_tokens, weather
from .. import panel_style, theme_tokens, weather
from ..auth import can_view_frame, current_user
from ..calendar_render import CALENDAR_VIEW_LABELS
from ..db import get_db
@@ -258,6 +258,16 @@ def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session =
"back_button_action": bindings.get("back", ""),
}
# The shared "Text size" card (_widget_font_scale_fields.html,
# models.Widget.font_scale) -- only included on the dialogs whose
# on-panel content is mostly body text (calendar/tasks; the other
# types are either image-only or, for text, already have their own
# richer per-widget font_size control -- see TextWidgetConfig).
font_scale_labels = {1.0: "Normal", 1.25: "Large", 1.5: "X-Large"}
font_scale_ctx = {
"font_scale_choices": [(v, font_scale_labels[v]) for v in panel_style.FONT_SCALE_CHOICES],
}
if widget.widget_type == "photos":
photo_cfg = db.get(PhotoWidgetConfig, widget.id)
return templates.TemplateResponse("_widget_dialog_photos.html", {
@@ -273,7 +283,7 @@ def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session =
"calendar_users": _calendar_users_for_widget(db, frame.id, widget.id, user.id),
"week_start_labels": WEEK_START_LABELS,
"calendar_color_labels": PALETTE_LABELS,
**border_ctx, **button_ctx,
**border_ctx, **button_ctx, **font_scale_ctx,
})
if widget.widget_type == "tasks":
@@ -282,7 +292,7 @@ def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session =
"request": request, "frame": frame, "widget": widget, "task_cfg": task_cfg, "user": user,
"task_users": _task_users_for_widget(db, frame.id, widget.id, user.id),
"task_color_labels": PALETTE_LABELS,
**border_ctx, **button_ctx,
**border_ctx, **button_ctx, **font_scale_ctx,
})
if widget.widget_type == "static":
@@ -199,6 +199,7 @@ function initCalendarDialog() {
document.getElementById('calendar-preview-refresh').addEventListener('click', loadCalendarPreview);
loadCalendarPreview();
initBorderFields();
initFontScaleFields();
initButtonActionFields();
}
@@ -0,0 +1,32 @@
// Shared "Text size" card (models.Widget.font_scale,
// _widget_font_scale_fields.html) -- only present on the calendar/tasks
// dialogs (see frame_pages.py's widget_dialog), same "one shared init
// function" shape as initBorderFields, just not included on every
// dialog since it isn't relevant to every widget type.
function initFontScaleFields() {
const select = document.getElementById('widget_font_scale');
if (!select) return; // dialog fragment didn't render the font-scale card -- shouldn't happen
document.getElementById('font-scale-config-form').addEventListener('submit', async (e) => {
e.preventDefault();
try {
const resp = await fetch(`${window.FRAME_API}/font-scale`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ font_scale: Number(select.value) }),
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Text size saved.');
// Whichever dialog is actually open -- both loadCalendarPreview and
// loadTasksPreview are always defined (plain script tags, not
// module-scoped), so checking the function's existence isn't
// enough; check for the <img> it actually targets instead (calling
// the wrong one throws setting .src on a null element).
if (document.getElementById('calendar-preview')) loadCalendarPreview();
if (document.getElementById('tasks-preview')) loadTasksPreview();
} catch (err) {
showStatus(false, err.message);
}
});
}
+1
View File
@@ -90,6 +90,7 @@ function initTasksDialog() {
document.getElementById('tasks-preview-refresh').addEventListener('click', loadTasksPreview);
loadTasksPreview();
initBorderFields();
initFontScaleFields();
initButtonActionFields();
}
@@ -135,6 +135,8 @@
{% include "_widget_border_fields.html" %}
{% include "_widget_font_scale_fields.html" %}
{% include "_widget_button_fields.html" %}
<section class="card" style="margin-top: 20px;">
@@ -67,6 +67,8 @@
{% include "_widget_border_fields.html" %}
{% include "_widget_font_scale_fields.html" %}
{% include "_widget_button_fields.html" %}
<section class="card" style="margin-top: 20px;">
@@ -0,0 +1,15 @@
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Text size</h2>
<p class="sub">Scales this widget's own text up for easier reading on
the panel -- rows/columns re-fit around the bigger text automatically.</p>
<form id="font-scale-config-form">
<label>Size
<select id="widget_font_scale">
{% for value, label in font_scale_choices %}
<option value="{{ value }}" {% if widget.font_scale == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<button type="submit">Save</button>
</form>
</section>
+1
View File
@@ -77,6 +77,7 @@
<script src="/static/widget_dialog_weather.js"></script>
<script src="/static/widget_dialog_battery.js"></script>
<script src="/static/widget_dialog_border.js"></script>
<script src="/static/widget_dialog_font_scale.js"></script>
<script src="/static/widget_dialog_button_actions.js"></script>
<script src="/static/frame_layout.js"></script>
<script src="/static/saved_layouts.js"></script>
+2 -2
View File
@@ -59,14 +59,14 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i
return calendar_html_render.build(
events, cfg.view, cfg.browse_offset, target_w, target_h, tz, cfg.week_start, frame.palette_rgb,
weather_cities, cfg.weather_units, cfg.week_days, cfg.week_layout, cfg.week_start_offset,
frame.theme,
frame.theme, widget.font_scale,
)
return _build(
events, view=cfg.view, browse_offset=cfg.browse_offset, target_w=target_w, target_h=target_h,
timezone=frame.timezone, fetch_summary=fetch_summary, week_start=cfg.week_start,
palette_rgb=frame.palette_rgb, weather_cities=weather_cities, weather_units=cfg.weather_units,
week_days=cfg.week_days, week_layout=cfg.week_layout,
week_days=cfg.week_days, week_layout=cfg.week_layout, font_scale=widget.font_scale,
week_start_offset=cfg.week_start_offset,
)
+3 -2
View File
@@ -43,8 +43,9 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i
# own classic path, should never pay for it.
from .. import html_render
return html_render.build_tasks(tasks, target_w, target_h, frame.palette_rgb, title, frame.theme)
return _build_tasks(tasks, target_w, target_h, frame.palette_rgb, title)
return html_render.build_tasks(tasks, target_w, target_h, frame.palette_rgb, title, frame.theme,
widget.font_scale)
return _build_tasks(tasks, target_w, target_h, frame.palette_rgb, title, font_scale=widget.font_scale)
ACTIONS: dict = {}
+20
View File
@@ -104,6 +104,8 @@ def test_expected_columns_exist_on_current_schema():
assert "render_style" in whiteboard_widget_columns # migration 37
calendar_widget_columns = {c["name"] for c in inspector.get_columns("calendar_widget_configs")}
assert "render_style" in calendar_widget_columns # migration 38
assert "theme" in frame_columns # migration 39
assert "font_scale" in widget_columns # migration 40
# --- widget system backfill (migration 16 + _ensure_widgets_backfilled) ---
@@ -362,6 +364,24 @@ def test_migration_30_adds_now_displaying_columns_to_an_existing_database(db_ses
assert frame.last_displayed_at == 0.0
def test_migration_40_adds_font_scale_to_an_existing_database(db_session):
"""Exercises _migration_40's real guarded ALTER path (widgets isn't
dropped/recreated by the pre-widget-system replay tests, so its
columns must be added defensively, same reasoning as migration
26/27/29/30's own comments)."""
with db_module.engine.begin() as conn:
conn.execute(text("UPDATE schema_version SET version = 39"))
run_migrations()
with db_module.engine.connect() as conn:
version = conn.execute(text("SELECT version FROM schema_version")).scalar()
assert version == MIGRATIONS[-1][0]
widget = db_session.query(Widget).filter(Widget.frame_id == 1).first()
assert widget.font_scale == 1.0
def test_migration_17_and_18_extract_tasks_into_a_standalone_multi_list_widget(db_session):
"""Exercises _migration_17 and _migration_18's actual data-extraction
SQL back to back (the real "existing widget-system database
+2 -1
View File
@@ -37,7 +37,8 @@ def test_set_border_persists(client, db_session):
assert resp.status_code == 200, resp.text
assert resp.json() == {
"id": widget_id, "widget_type": "photos", "x": 0, "y": 0, "w": 8, "h": 5, "sort_order": 0,
"border_style": "dashed", "border_thickness": 5, "border_color_index": 3, "locked": False,
"border_style": "dashed", "border_thickness": 5, "border_color_index": 3, "font_scale": 1.0,
"locked": False,
}
widget = db_session.get(Widget, widget_id)
assert (widget.border_style, widget.border_thickness, widget.border_color_index) == ("dashed", 5, 3)
+184
View File
@@ -0,0 +1,184 @@
"""routers/api_widgets.py's POST .../font-scale endpoint (models.Widget.
font_scale) -- a Widget-level property, not a per-type config field, same
reasoning/shape as test_widget_border.py's border coverage. The second
half confirms the actual render threading (widgets/calendar.py and
widgets/tasks.py pass widget.font_scale into both the classic and modern
builders), the same "spy on the resolve call" approach test_widgets_tasks.
py/test_widgets_calendar.py already use for frame.theme threading."""
from __future__ import annotations
import time
from app.models import CalendarWidgetConfig, Frame, TaskWidgetConfig, Widget
from .conftest import csrf_headers, link_user, login, make_user
def _widget_id(db_session, widget_type="photos") -> int:
return db_session.query(Widget).filter_by(frame_id=1, widget_type=widget_type).one().id
def test_new_widget_defaults_to_normal_font_scale(db_session):
widget = db_session.query(Widget).filter_by(frame_id=1).one()
assert widget.font_scale == 1.0
def test_set_font_scale_persists(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget_id = _widget_id(db_session)
resp = client.post(f"/api/frames/1/widgets/{widget_id}/font-scale",
json={"font_scale": 1.25}, headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
assert resp.json()["font_scale"] == 1.25
widget = db_session.get(Widget, widget_id)
assert widget.font_scale == 1.25
def test_set_font_scale_rejects_a_value_outside_the_fixed_choices(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget_id = _widget_id(db_session)
resp = client.post(f"/api/frames/1/widgets/{widget_id}/font-scale",
json={"font_scale": 3.0}, headers=csrf_headers(client))
assert resp.status_code == 400
assert "font_scale" in resp.json()["detail"]
assert db_session.get(Widget, widget_id).font_scale == 1.0
def test_set_font_scale_404s_for_unknown_widget(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
resp = client.post("/api/frames/1/widgets/999999/font-scale",
json={"font_scale": 1.25}, headers=csrf_headers(client))
assert resp.status_code == 404
def test_set_font_scale_unrelated_user_404s(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
make_user(db_session, "mallory")
widget_id = _widget_id(db_session)
client.cookies.clear()
login(client, "mallory")
resp = client.post(f"/api/frames/1/widgets/{widget_id}/font-scale",
json={"font_scale": 1.25}, headers=csrf_headers(client))
assert resp.status_code == 404
assert db_session.get(Widget, widget_id).font_scale == 1.0
def test_set_font_scale_linked_but_not_controlling_user_409s(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
bob = make_user(db_session, "bob")
frame = db_session.get(Frame, 1)
link_user(db_session, bob, frame)
widget_id = _widget_id(db_session)
client.cookies.clear()
login(client, "bob")
resp = client.post(f"/api/frames/1/widgets/{widget_id}/font-scale",
json={"font_scale": 1.25}, headers=csrf_headers(client))
assert resp.status_code == 409
assert resp.json()["detail"]["error"] == "not_controller"
# --- actually threads through to the renderers -----------------------------
def test_calendar_classic_render_threads_font_scale_through(db_session, monkeypatch):
"""Confirms widgets/calendar.py's classic branch passes widget.
font_scale into calendar_render._build, by spying on panel_style.
scaled_size (every classic builder's one shared scale point -- see
its own docstring)."""
from app import panel_style, widgets
frame = db_session.get(Frame, 1)
widget = Widget(frame_id=frame.id, widget_type="calendar", x=0, y=0, w=8, h=5,
sort_order=0, created_at=time.time(), font_scale=1.5)
db_session.add(widget)
db_session.flush()
db_session.add(CalendarWidgetConfig(widget_id=widget.id, view="agenda"))
db_session.commit()
monkeypatch.setattr(widgets.calendar, "get_or_refresh_calendar_events_for_widget",
lambda db, frame, widget: ([], ""))
monkeypatch.setattr(widgets.calendar, "get_or_refresh_weather_for_widget",
lambda db, frame, widget: [])
seen_scales = []
real_scaled_size = panel_style.scaled_size
monkeypatch.setattr(panel_style, "scaled_size", lambda v, s: (seen_scales.append(s), real_scaled_size(v, s))[1])
widgets.calendar.render(db_session, frame, widget, 300, 200)
assert seen_scales and all(s == 1.5 for s in seen_scales)
def test_calendar_modern_render_threads_font_scale_through(db_session, monkeypatch):
from PIL import Image
from app import html_render, widgets
frame = db_session.get(Frame, 1)
widget = Widget(frame_id=frame.id, widget_type="calendar", x=0, y=0, w=8, h=5,
sort_order=0, created_at=time.time(), font_scale=1.25)
db_session.add(widget)
db_session.flush()
db_session.add(CalendarWidgetConfig(widget_id=widget.id, view="agenda", render_style="modern"))
db_session.commit()
monkeypatch.setattr(widgets.calendar, "get_or_refresh_calendar_events_for_widget",
lambda db, frame, widget: ([], ""))
monkeypatch.setattr(widgets.calendar, "get_or_refresh_weather_for_widget",
lambda db, frame, widget: [])
monkeypatch.setattr(html_render, "render_html_to_image",
lambda html, target_w, target_h: Image.new("RGB", (target_w, target_h), (255, 255, 255)))
from app import panel_style
seen_scales = []
real_scaled_size = panel_style.scaled_size
monkeypatch.setattr(panel_style, "scaled_size", lambda v, s: (seen_scales.append(s), real_scaled_size(v, s))[1])
widgets.calendar.render(db_session, frame, widget, 300, 200)
assert seen_scales and all(s == 1.25 for s in seen_scales)
def test_tasks_classic_render_threads_font_scale_through(db_session, monkeypatch):
from app import panel_style, widgets
frame = db_session.get(Frame, 1)
widget = Widget(frame_id=frame.id, widget_type="tasks", x=0, y=0, w=2, h=2,
sort_order=0, created_at=time.time(), font_scale=1.5)
db_session.add(widget)
db_session.flush()
db_session.add(TaskWidgetConfig(widget_id=widget.id))
db_session.commit()
monkeypatch.setattr(widgets.tasks, "get_or_refresh_tasks_for_widget", lambda db, frame, widget: [])
seen_scales = []
real_scaled_size = panel_style.scaled_size
monkeypatch.setattr(panel_style, "scaled_size", lambda v, s: (seen_scales.append(s), real_scaled_size(v, s))[1])
widgets.tasks.render(db_session, frame, widget, 300, 200)
assert seen_scales and all(s == 1.5 for s in seen_scales)
def test_tasks_modern_render_threads_font_scale_through(db_session, monkeypatch):
from PIL import Image
from app import html_render, panel_style, widgets
frame = db_session.get(Frame, 1)
widget = Widget(frame_id=frame.id, widget_type="tasks", x=0, y=0, w=2, h=2,
sort_order=0, created_at=time.time(), font_scale=1.25)
db_session.add(widget)
db_session.flush()
db_session.add(TaskWidgetConfig(widget_id=widget.id, render_style="modern"))
db_session.commit()
monkeypatch.setattr(widgets.tasks, "get_or_refresh_tasks_for_widget", lambda db, frame, widget: [])
monkeypatch.setattr(html_render, "render_html_to_image",
lambda html, target_w, target_h: Image.new("RGB", (target_w, target_h), (255, 255, 255)))
seen_scales = []
real_scaled_size = panel_style.scaled_size
monkeypatch.setattr(panel_style, "scaled_size", lambda v, s: (seen_scales.append(s), real_scaled_size(v, s))[1])
widgets.tasks.render(db_session, frame, widget, 300, 200)
assert seen_scales and all(s == 1.25 for s in seen_scales)
+2 -2
View File
@@ -76,9 +76,9 @@ def _capture_build_tasks_title(monkeypatch):
seen_titles = []
real_build_tasks = widgets.tasks._build_tasks
def spy(tasks, target_w, target_h, palette_rgb=None, title="Tasks"):
def spy(tasks, target_w, target_h, palette_rgb=None, title="Tasks", font_scale=1.0):
seen_titles.append(title)
return real_build_tasks(tasks, target_w, target_h, palette_rgb, title)
return real_build_tasks(tasks, target_w, target_h, palette_rgb, title, font_scale=font_scale)
monkeypatch.setattr(widgets.tasks, "_build_tasks", spy)
return seen_titles