diff --git a/docs/widgets.md b/docs/widgets.md index 1754732..a08aaa9 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -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`, diff --git a/server/app/calendar_html_render.py b/server/app/calendar_html_render.py index 502a592..0b316ca 100644 --- a/server/app/calendar_html_render.py +++ b/server/app/calendar_html_render.py @@ -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) diff --git a/server/app/calendar_render.py b/server/app/calendar_render.py index e33d177..1e1c95b 100644 --- a/server/app/calendar_render.py +++ b/server/app/calendar_render.py @@ -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() diff --git a/server/app/html_render.py b/server/app/html_render.py index 4a13b86..644c206 100644 --- a/server/app/html_render.py +++ b/server/app/html_render.py @@ -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 diff --git a/server/app/migration.py b/server/app/migration.py index 225f67d..19bccfb 100644 --- a/server/app/migration.py +++ b/server/app/migration.py @@ -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), ] diff --git a/server/app/models.py b/server/app/models.py index 4f319b9..cf01e61 100644 --- a/server/app/models.py +++ b/server/app/models.py @@ -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"),) diff --git a/server/app/panel_style.py b/server/app/panel_style.py index c9198d5..9c1ad54 100644 --- a/server/app/panel_style.py +++ b/server/app/panel_style.py @@ -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 + {% for value, label in font_scale_choices %} + + {% endfor %} + + + + + diff --git a/server/app/templates/frame_layout.html b/server/app/templates/frame_layout.html index ef7ce4c..f464dde 100644 --- a/server/app/templates/frame_layout.html +++ b/server/app/templates/frame_layout.html @@ -77,6 +77,7 @@ + diff --git a/server/app/widgets/calendar.py b/server/app/widgets/calendar.py index d0c32ca..966b9e6 100644 --- a/server/app/widgets/calendar.py +++ b/server/app/widgets/calendar.py @@ -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, ) diff --git a/server/app/widgets/tasks.py b/server/app/widgets/tasks.py index 65c116d..89213d9 100644 --- a/server/app/widgets/tasks.py +++ b/server/app/widgets/tasks.py @@ -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 = {} diff --git a/server/tests/test_migrations.py b/server/tests/test_migrations.py index 4579bc3..e6917c1 100644 --- a/server/tests/test_migrations.py +++ b/server/tests/test_migrations.py @@ -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 diff --git a/server/tests/test_widget_border.py b/server/tests/test_widget_border.py index 9d158de..22ba495 100644 --- a/server/tests/test_widget_border.py +++ b/server/tests/test_widget_border.py @@ -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) diff --git a/server/tests/test_widget_font_scale.py b/server/tests/test_widget_font_scale.py new file mode 100644 index 0000000..1804628 --- /dev/null +++ b/server/tests/test_widget_font_scale.py @@ -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) diff --git a/server/tests/test_widgets_tasks.py b/server/tests/test_widgets_tasks.py index 0ae8774..3906b6b 100644 --- a/server/tests/test_widgets_tasks.py +++ b/server/tests/test_widgets_tasks.py @@ -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