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:
@@ -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 = {}
|
||||
|
||||
Reference in New Issue
Block a user