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
+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