Add a per-widget text-size picker for calendar/tasks legibility
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:
@@ -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`,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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"),)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user