Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ea1c53ec3 | ||
|
|
d34eb1bf45 | ||
|
|
bcea090e73 | ||
|
|
37d57a1f88 | ||
|
|
d974e872ba | ||
|
|
dfe9d71971 |
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: make-widget
|
name: make-widget
|
||||||
description: Scaffold a new widget type for the espresso_frame server (the ~13-file checklist a widget type touches -- config table, grid footprint, render module, registry, migration, config-save + endpoints, dialog template + JS, script tag, WIDGET_LABELS, docs, tests). Use when asked to add a new widget type to a frame's panel (e.g. "add a text widget", "add an RSS widget", "add a weather-only widget").
|
description: Scaffold a new widget type for the espresso_frame server (the ~14-file checklist a widget type touches -- config table, grid footprint, render module, registry, migration, config-save + endpoints, dialog template + JS, script tag, WIDGET_LABELS, docs, saved-layout config allowlist, tests). Use when asked to add a new widget type to a frame's panel (e.g. "add a text widget", "add an RSS widget", "add a weather-only widget").
|
||||||
---
|
---
|
||||||
|
|
||||||
Adding a widget type is a very consistent, repeated pattern in this
|
Adding a widget type is a very consistent, repeated pattern in this
|
||||||
@@ -114,6 +114,16 @@ Pick your template accordingly:
|
|||||||
`MIN_FOOTPRINT` prose line, the `app/widgets/` module list. This is
|
`MIN_FOOTPRINT` prose line, the `app/widgets/` module list. This is
|
||||||
the project's own "start here" doc per `CLAUDE.md` -- don't ship a
|
the project's own "start here" doc per `CLAUDE.md` -- don't ship a
|
||||||
widget without it staying accurate.
|
widget without it staying accurate.
|
||||||
|
14. **`app/routers/api_layouts.py`** -- add a `"<type>": (...)` entry to
|
||||||
|
`LAYOUT_CONFIG_FIELDS` listing the config columns that are an
|
||||||
|
authored *setting* (as opposed to runtime/cache state like a fetch
|
||||||
|
cache or queue position, which a saved layout deliberately leaves
|
||||||
|
out -- see the dict's own comment). Skipping this doesn't error or
|
||||||
|
warn anywhere: the widget just silently saves/applies with an empty
|
||||||
|
`{}` config forever, resetting to defaults on every layout apply or
|
||||||
|
hold-to-cycle. This actually shipped missing for the weather widget
|
||||||
|
-- caught only because a user noticed layout-cycling kept resetting
|
||||||
|
its city/mode.
|
||||||
|
|
||||||
## Tests (`server/tests/`)
|
## Tests (`server/tests/`)
|
||||||
|
|
||||||
@@ -138,6 +148,13 @@ Pick your template accordingly:
|
|||||||
- Any pure-logic helper module (decoding, parsing -- like
|
- Any pure-logic helper module (decoding, parsing -- like
|
||||||
`app/image_upload.py`) gets its own `test_<module>.py`: no HTTP, no
|
`app/image_upload.py`) gets its own `test_<module>.py`: no HTTP, no
|
||||||
DB, just the function.
|
DB, just the function.
|
||||||
|
- `test_saved_layouts.py` -- a `test_save_and_apply_round_trip_<type>_settings`
|
||||||
|
test: set every field the new `LAYOUT_CONFIG_FIELDS` entry lists,
|
||||||
|
save a layout, assert the `SavedLayoutWidget.config` snapshot has them
|
||||||
|
all, delete the frame's widgets, apply the layout back, assert the
|
||||||
|
new widget's config matches -- and that any runtime/cache field
|
||||||
|
(`checked_at`, a fetch cache, a queue) was *not* carried over. See
|
||||||
|
`test_save_and_apply_round_trip_weather_settings` for the pattern.
|
||||||
|
|
||||||
Run the full suite before calling it done:
|
Run the full suite before calling it done:
|
||||||
|
|
||||||
|
|||||||
@@ -269,6 +269,25 @@ modes (`WeatherWidgetConfig.mode`, switchable in the widget's dialog like
|
|||||||
side -- the calendar widget's embedded strip, as a standalone
|
side -- the calendar widget's embedded strip, as a standalone
|
||||||
widget's whole content instead of a strip above an agenda day.
|
widget's whole content instead of a strip above an agenda day.
|
||||||
|
|
||||||
|
**Render style** (`WeatherWidgetConfig.render_style`, `"classic"` default
|
||||||
|
| `"modern"`, experimental): `current`/`daily` only -- `hourly`/
|
||||||
|
`multi_city` always render classic regardless of this setting. `modern`
|
||||||
|
draws the widget as an HTML/CSS card (Jinja2 templates under
|
||||||
|
`app/templates/widget_html/`) through a persistent headless Chromium
|
||||||
|
browser (`app/html_render.py`, Playwright) instead of `app/weather_render.
|
||||||
|
py`'s hand-drawn PIL primitives -- gradients/shadows/soft icon shading
|
||||||
|
PIL can't easily do. Its own `ordered_dither` (Bayer/ordered, not Floyd-
|
||||||
|
Steinberg) commits the rendered widget to exact palette colors *before*
|
||||||
|
compositing, so it's safe to mix with photo/other classic-rendered
|
||||||
|
widgets on the same frame without a Floyd-Steinberg seam at the boundary
|
||||||
|
(see that module's docstring for why ordered dithering doesn't have this
|
||||||
|
problem and Floyd-Steinberg does) -- no `Frame`-level dithering setting
|
||||||
|
was needed. Playwright/Chromium is a real, heavyweight runtime dependency
|
||||||
|
imported lazily only when a weather widget actually uses this style, and
|
||||||
|
its Docker packaging has a known likely-blocking image-size problem not
|
||||||
|
yet resolved (see `server/Dockerfile`'s own comment) -- treat this style
|
||||||
|
as unshipped/local-only until that's sorted out.
|
||||||
|
|
||||||
`current`/`hourly`/`daily` share one configured location
|
`current`/`hourly`/`daily` share one configured location
|
||||||
(`city_label`/`city_latitude`/`city_longitude`, set via `POST .../
|
(`city_label`/`city_latitude`/`city_longitude`, set via `POST .../
|
||||||
weather-location`, geocoded through `weather.geocode_city`); `multi_city`
|
weather-location`, geocoded through `weather.geocode_city`); `multi_city`
|
||||||
|
|||||||
@@ -36,9 +36,62 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
tzdata fontconfig fonts-dejavu-core nodejs npm \
|
tzdata fontconfig fonts-dejavu-core nodejs npm \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# EXPERIMENTAL, likely unmergeable as-is -- see below. System libs a
|
||||||
|
# headless Chromium needs (app/html_render.py, the weather widget's
|
||||||
|
# opt-in "modern" render style), trimmed from Playwright's own full
|
||||||
|
# `install-deps chromium` list to just what a headless (no Xvfb),
|
||||||
|
# Latin-text-plus-emoji use case needs: dropped xvfb (only needed for a
|
||||||
|
# *headed* browser) and the CJK/Cyrillic/Thai locale font packages
|
||||||
|
# (fonts-ipafont-gothic, fonts-wqy-zenhei, fonts-tlwg-loma-otf,
|
||||||
|
# xfonts-cyrillic, xfonts-scalable, fonts-freefont-ttf, fonts-unifont) --
|
||||||
|
# fonts-noto-color-emoji is the one that actually matters here (real
|
||||||
|
# color emoji in the weather icons, vs. WeasyPrint/Pango's monochrome
|
||||||
|
# fallback glyphs in this feature's original spike).
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libasound2t64 libatk-bridge2.0-0t64 libatk1.0-0t64 libatspi2.0-0t64 \
|
||||||
|
libcairo2 libcups2t64 libdbus-1-3 libdrm2 libgbm1 libglib2.0-0t64 \
|
||||||
|
libnspr4 libnss3 libpango-1.0-0 libx11-6 libxcb1 libxcomposite1 \
|
||||||
|
libxdamage1 libxext6 libxfixes3 libxkbcommon0 libxrandr2 \
|
||||||
|
fonts-noto-color-emoji libfontconfig1 libfreetype6 fonts-liberation \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
|
# Split across several layers rather than one `pip install -r
|
||||||
|
# requirements.txt` -- same Cloudflare single-blob/layer payload-size
|
||||||
|
# limit as render-service's npm installs below. The single combined
|
||||||
|
# layer was measured at ~113MB unpacked, over the limit on its own.
|
||||||
|
# Isolating the largest packages gets every layer's unpacked size well
|
||||||
|
# clear of 100MB (sqlalchemy ~15MB, pillow ~19MB, pypdfium2 ~8MB, the
|
||||||
|
# remaining `-r requirements.txt` layer ~71MB). Each package version here
|
||||||
|
# still comes from requirements.txt (`pip install -r` for everything that
|
||||||
|
# doesn't need its own layer skips these, since pip sees them already
|
||||||
|
# satisfied); the explicit versions below just control *when* each
|
||||||
|
# installs -- same "single source of truth, just splitting *when* it
|
||||||
|
# installs" tradeoff as the npm section's --no-save comment below.
|
||||||
|
RUN pip install --no-cache-dir sqlalchemy==2.0.51
|
||||||
|
RUN pip install --no-cache-dir pillow==12.3.0
|
||||||
|
RUN pip install --no-cache-dir pypdfium2==5.12.1
|
||||||
|
RUN pip install --no-cache-dir playwright==1.61.0
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# KNOWN LIKELY BLOCKER, not resolved by pulling this into its own layer:
|
||||||
|
# `playwright install chromium-headless-shell` unpacks to ~262MB, and its
|
||||||
|
# single `chrome-headless-shell` binary alone (measured: 181MB) is one
|
||||||
|
# file -- unlike the pip/npm splits above (independently-installable
|
||||||
|
# smaller packages moved into their own layers), a single 181MB file
|
||||||
|
# can't be divided across multiple <100MB Docker layers by any ordinary
|
||||||
|
# COPY/RUN restructuring; the whole file lands in whichever layer's diff
|
||||||
|
# contains it. This almost certainly exceeds the same Cloudflare single-
|
||||||
|
# blob/layer limit that forced the pip/npm splits elsewhere in this file
|
||||||
|
# (see their comments) -- an actual push to this project's registry
|
||||||
|
# hasn't been attempted (would require pushing to `main`, which triggers
|
||||||
|
# deploy) to confirm, but there is no reason to expect a single 181MB
|
||||||
|
# blob to fit where combined ~113MB of many small wheels didn't. Needs a
|
||||||
|
# real resolution (a registry without this limit, hosting the browser
|
||||||
|
# binary outside the image, etc.) before this branch can actually ship --
|
||||||
|
# tracked as open, not silently assumed away.
|
||||||
|
RUN playwright install chromium-headless-shell
|
||||||
|
|
||||||
# render-service/'s dependencies installed as several separate layers
|
# render-service/'s dependencies installed as several separate layers
|
||||||
# rather than one `npm install` covering all of them -- a from-scratch
|
# rather than one `npm install` covering all of them -- a from-scratch
|
||||||
# push of this image once hit Cloudflare's payload-size limit on a
|
# push of this image once hit Cloudflare's payload-size limit on a
|
||||||
|
|||||||
+138
-115
@@ -11,7 +11,7 @@ Event dicts here are calendar_feed.py's shape: {"summary", "start", "end"
|
|||||||
(ISO 8601 strings), "all_day", "sources": [{"owner_display_name",
|
(ISO 8601 strings), "all_day", "sources": [{"owner_display_name",
|
||||||
"color_index"}, ...]} -- more than one entry in "sources" means
|
"color_index"}, ...]} -- more than one entry in "sources" means
|
||||||
merge_events collapsed several calendars' identical (same title/time)
|
merge_events collapsed several calendars' identical (same title/time)
|
||||||
events into one, see _event_colors/_draw_color_bar below.
|
events into one, see _event_colors/panel_style.draw_color_chip below.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -26,6 +26,7 @@ from zoneinfo import ZoneInfo
|
|||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
from . import panel_style
|
||||||
from .image_pipeline import (
|
from .image_pipeline import (
|
||||||
DEFAULT_PALETTE_RGB,
|
DEFAULT_PALETTE_RGB,
|
||||||
_apply_manage_overlay,
|
_apply_manage_overlay,
|
||||||
@@ -42,12 +43,20 @@ CALENDAR_VIEW_LABELS = {"agenda": "Agenda (today)", "today_tomorrow": "Agenda (t
|
|||||||
"week": "Week", "month": "Month"}
|
"week": "Week", "month": "Month"}
|
||||||
WEEKDAY_NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
|
WEEKDAY_NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
|
||||||
|
|
||||||
MARGIN = 20
|
# MARGIN carries panel_style.CONTENT_MARGIN's value unchanged (not
|
||||||
|
# re-tuned). BG/FG are this module's own plain black/white -- checkbox
|
||||||
|
# outlines, month-view grid hairlines -- not a text-emphasis concern (no
|
||||||
|
# MUTED gray here anymore -- see panel_style's module docstring for why:
|
||||||
|
# a mid-gray fill has no close palette match and dithers into speckle
|
||||||
|
# once the whole canvas is quantized. Secondary text now reads through
|
||||||
|
# size/weight alone, always exact black).
|
||||||
|
MARGIN = panel_style.CONTENT_MARGIN
|
||||||
BG = (255, 255, 255)
|
BG = (255, 255, 255)
|
||||||
FG = (0, 0, 0)
|
FG = (0, 0, 0)
|
||||||
MUTED = (110, 110, 110)
|
# Structural dividers/grid lines (between stacked day sections, week
|
||||||
# Was a light gray, but that dithers away to near-invisible once quantized
|
# columns, month cells) stay a plain black rule -- gray dithers away to
|
||||||
# to the 6-color e-ink palette -- black reads as an actual line on-panel.
|
# near-invisible once quantized to the 6-color e-ink palette. Headers
|
||||||
|
# no longer use this: see panel_style.draw_header_bar/theme_color.
|
||||||
RULE = (0, 0, 0)
|
RULE = (0, 0, 0)
|
||||||
|
|
||||||
# Fallback for any event whose calendar has no manually pinned color
|
# Fallback for any event whose calendar has no manually pinned color
|
||||||
@@ -67,7 +76,7 @@ def _event_colors(event: dict, owners_seen: list[str], palette_rgb: list | None)
|
|||||||
one person's calendar). Usually just one color; more than one is
|
one person's calendar). Usually just one color; more than one is
|
||||||
what tells the "same event, more than one calendar" case apart from
|
what tells the "same event, more than one calendar" case apart from
|
||||||
an ordinary single-calendar event at render time -- see
|
an ordinary single-calendar event at render time -- see
|
||||||
_draw_color_bar. Each source's own manually pinned color
|
panel_style.draw_color_chip. Each source's own manually pinned color
|
||||||
(FrameCalendar.color_index -- see routers/api_widgets.py's
|
(FrameCalendar.color_index -- see routers/api_widgets.py's
|
||||||
api_widget_calendar_color) resolves against whichever palette this frame
|
api_widget_calendar_color) resolves against whichever palette this frame
|
||||||
actually renders with, so a pinned "Blue" stays this frame's actual
|
actually renders with, so a pinned "Blue" stays this frame's actual
|
||||||
@@ -91,24 +100,6 @@ def _event_colors(event: dict, owners_seen: list[str], palette_rgb: list | None)
|
|||||||
return colors
|
return colors
|
||||||
|
|
||||||
|
|
||||||
def _draw_color_bar(draw: ImageDraw.ImageDraw, x0: int, y0: int, x1: int, y1: int,
|
|
||||||
colors: list[tuple[int, int, int]], radius: int) -> None:
|
|
||||||
"""One rounded bar for a single-source event, or that same overall
|
|
||||||
footprint split into equal-width side-by-side segments -- one per
|
|
||||||
contributing calendar -- for a deduplicated shared event (see
|
|
||||||
_event_colors/calendar_feed.merge_events). Splitting rather than
|
|
||||||
e.g. concentric rings keeps every color equally "thick and bold" at
|
|
||||||
a glance, the same design goal a single pinned color already has."""
|
|
||||||
if len(colors) == 1:
|
|
||||||
draw.rounded_rectangle([x0, y0, x1, y1], radius=radius, fill=colors[0])
|
|
||||||
return
|
|
||||||
seg_w = (x1 - x0) / len(colors)
|
|
||||||
for i, color in enumerate(colors):
|
|
||||||
seg_x0 = round(x0 + i * seg_w)
|
|
||||||
seg_x1 = round(x0 + (i + 1) * seg_w) - (2 if i < len(colors) - 1 else 0)
|
|
||||||
draw.rectangle([seg_x0, y0, seg_x1, y1], fill=color)
|
|
||||||
|
|
||||||
|
|
||||||
def _event_start(event: dict, tz: ZoneInfo) -> datetime | date:
|
def _event_start(event: dict, tz: ZoneInfo) -> datetime | date:
|
||||||
"""Parses event["start"] and, for timed events, converts to `tz` --
|
"""Parses event["start"] and, for timed events, converts to `tz` --
|
||||||
calendar_feed.py stores whatever timezone each source event carried
|
calendar_feed.py stores whatever timezone each source event carried
|
||||||
@@ -158,11 +149,12 @@ def _fmt_task_due(due: str | None) -> str:
|
|||||||
return d.strftime("%b %-d")
|
return d.strftime("%b %-d")
|
||||||
|
|
||||||
|
|
||||||
# ImageFont.load_default() (used for everything else in this module --
|
# Neither Inter (panel_style.font_bold/font_regular, this module's own
|
||||||
# see the module docstring) has no emoji glyphs, and PIL/FreeType don't
|
# body/title font -- see MARGIN/BG/FG comment above) nor PIL's bundled
|
||||||
# skip an unsupported codepoint, they substitute a ".notdef" tofu box (a
|
# default font has emoji glyphs, and PIL/FreeType don't skip an
|
||||||
# visible filled rectangle) -- reads as a rendering glitch, not "emoji
|
# unsupported codepoint, they substitute a ".notdef" tofu box (a visible
|
||||||
# not supported". So event titles get drawn with two fonts: the normal
|
# filled rectangle) -- reads as a rendering glitch, not "emoji not
|
||||||
|
# supported". So event titles get drawn with two fonts: the normal
|
||||||
# text font for everything else, and one of these for actual emoji runs
|
# text font for everything else, and one of these for actual emoji runs
|
||||||
# (see _split_emoji_runs/_draw_mixed_line) -- both Noto Emoji, OFL-1.1,
|
# (see _split_emoji_runs/_draw_mixed_line) -- both Noto Emoji, OFL-1.1,
|
||||||
# vendored at app/fonts/ (license alongside at app/fonts/OFL.txt).
|
# vendored at app/fonts/ (license alongside at app/fonts/OFL.txt).
|
||||||
@@ -401,15 +393,17 @@ def _draw_agenda_day(img: Image.Image, draw: ImageDraw.ImageDraw, day: date, eve
|
|||||||
these vertically without duplicating the row-layout/truncation
|
these vertically without duplicating the row-layout/truncation
|
||||||
logic. Weather is drawn above the event list -- eating into the same
|
logic. Weather is drawn above the event list -- eating into the same
|
||||||
row budget the event count is truncated against, exactly like the
|
row budget the event count is truncated against, exactly like the
|
||||||
header/rule above it already does."""
|
header bar above it already does."""
|
||||||
x0, y0, w, h = region
|
x0, y0, w, h = region
|
||||||
text_x0, text_y0 = x0 + MARGIN, y0 + MARGIN
|
header_h = title_font.size + 20
|
||||||
|
panel_style.draw_header_bar(draw, (x0, y0, w, header_h), header_h,
|
||||||
|
panel_style.theme_color("calendar", palette_rgb))
|
||||||
|
text_x0 = x0 + MARGIN
|
||||||
text_w = w - MARGIN * 2
|
text_w = w - MARGIN * 2
|
||||||
header = day.strftime("%A, %B ") + str(day.day)
|
header = day.strftime("%A, %B ") + str(day.day)
|
||||||
draw_text(img, (text_x0, text_y0), _truncate_to_width(draw, header, title_font, text_w), title_font)
|
draw_text(img, (text_x0, y0 + (header_h - title_font.size) // 2),
|
||||||
y = text_y0 + title_font.size + 12
|
_truncate_to_width(draw, header, title_font, text_w), title_font, BG)
|
||||||
draw.line([(text_x0, y), (text_x0 + text_w, y)], fill=RULE)
|
y = y0 + header_h + 12
|
||||||
y += 12
|
|
||||||
|
|
||||||
weather_entries = _weather_for_day(weather_cities, day)
|
weather_entries = _weather_for_day(weather_cities, day)
|
||||||
if weather_entries:
|
if weather_entries:
|
||||||
@@ -422,13 +416,13 @@ def _draw_agenda_day(img: Image.Image, draw: ImageDraw.ImageDraw, day: date, eve
|
|||||||
max_rows = max(0, (y0 + h - MARGIN - y) // row_h)
|
max_rows = max(0, (y0 + h - MARGIN - y) // row_h)
|
||||||
|
|
||||||
if not day_events:
|
if not day_events:
|
||||||
draw_text(img, (text_x0, y), "Nothing scheduled", body_font, MUTED)
|
draw_text(img, (text_x0, y), "Nothing scheduled", body_font)
|
||||||
for i, event in enumerate(day_events):
|
for i, event in enumerate(day_events):
|
||||||
if i >= max_rows:
|
if i >= max_rows:
|
||||||
draw_text(img, (text_x0, y), f"+{len(day_events) - max_rows} more", body_font, MUTED)
|
draw_text(img, (text_x0, y), f"+{len(day_events) - max_rows} more", body_font)
|
||||||
break
|
break
|
||||||
colors = _event_colors(event, owners_seen, palette_rgb)
|
colors = _event_colors(event, owners_seen, palette_rgb)
|
||||||
_draw_color_bar(draw, text_x0, y + 2, text_x0 + 10, y + row_h - 7, colors, radius=3)
|
panel_style.draw_color_chip(draw, text_x0, y + 2, text_x0 + 10, y + row_h - 7, colors)
|
||||||
time_str = "All day" if event["all_day"] else _fmt_time(_event_start(event, tz))
|
time_str = "All day" if event["all_day"] else _fmt_time(_event_start(event, tz))
|
||||||
prefix = f"{time_str} "
|
prefix = f"{time_str} "
|
||||||
draw_text(img, (text_x0 + 18, y), prefix, body_font)
|
draw_text(img, (text_x0 + 18, y), prefix, body_font)
|
||||||
@@ -445,13 +439,13 @@ def _draw_tasks(img: Image.Image, draw: ImageDraw.ImageDraw, region: tuple[int,
|
|||||||
(`title`, truncated to fit -- TaskWidgetConfig.name or the "Tasks"
|
(`title`, truncated to fit -- TaskWidgetConfig.name or the "Tasks"
|
||||||
default; the only widget type with its own on-panel title, since
|
default; the only widget type with its own on-panel title, since
|
||||||
it's the only one where "which list is this" isn't obvious from its
|
it's the only one where "which list is this" isn't obvious from its
|
||||||
content the way a calendar/photo/whiteboard's is), then a color bar
|
content the way a calendar/photo/whiteboard's is), then a color chip
|
||||||
(reusing _event_colors/_draw_color_bar as-is: a task dict's
|
(reusing _event_colors/panel_style.draw_color_chip as-is: a task
|
||||||
top-level owner_display_name/color_index is exactly _event_colors'
|
dict's top-level owner_display_name/color_index is exactly
|
||||||
single-source fallback shape, since caldav_client.merge_tasks
|
_event_colors' single-source fallback shape, since caldav_client.
|
||||||
doesn't cross-list-dedup tasks into a "sources" list the way
|
merge_tasks doesn't cross-list-dedup tasks into a "sources" list the
|
||||||
merge_events dedups events) + checkbox glyph + due date (if any) +
|
way merge_events dedups events) + checkbox glyph + due date (if any)
|
||||||
summary per task, same header/rule/row-cap/truncation shape as
|
+ summary per task, same header/row-cap/truncation shape as
|
||||||
_draw_agenda_day's event list so the standalone tasks widget (see
|
_draw_agenda_day's event list so the standalone tasks widget (see
|
||||||
_build_tasks) reads as the same consistent design as everything
|
_build_tasks) reads as the same consistent design as everything
|
||||||
else on-panel, not a bolted-together look. Reuses _draw_mixed_line
|
else on-panel, not a bolted-together look. Reuses _draw_mixed_line
|
||||||
@@ -460,45 +454,52 @@ def _draw_tasks(img: Image.Image, draw: ImageDraw.ImageDraw, region: tuple[int,
|
|||||||
|
|
||||||
Outstanding tasks get an empty checkbox; completed ones (only ever
|
Outstanding tasks get an empty checkbox; completed ones (only ever
|
||||||
present when TaskWidgetConfig.show_completed is on -- see
|
present when TaskWidgetConfig.show_completed is on -- see
|
||||||
caldav_client.fetch_tasks' completed_since) get a filled one and
|
caldav_client.fetch_tasks' completed_since) get a filled checkbox in
|
||||||
muted text, no due-date prefix (irrelevant once done)."""
|
this widget's own Green accent (see panel_style.THEME) -- that fill
|
||||||
|
is the "done" signal, no due-date prefix (irrelevant once done) and
|
||||||
|
no separate muted text treatment (see module-level MUTED removal
|
||||||
|
note above _event_colors)."""
|
||||||
x0, y0, w, h = region
|
x0, y0, w, h = region
|
||||||
text_x0, text_y0 = x0 + MARGIN, y0 + MARGIN
|
header_h = title_font.size + 20
|
||||||
|
panel_style.draw_header_bar(draw, (x0, y0, w, header_h), header_h,
|
||||||
|
panel_style.theme_color("tasks", palette_rgb))
|
||||||
|
text_x0 = x0 + MARGIN
|
||||||
text_w = w - MARGIN * 2
|
text_w = w - MARGIN * 2
|
||||||
draw_text(img, (text_x0, text_y0), _truncate_to_width(draw, title or "Tasks", title_font, text_w), title_font)
|
draw_text(img, (text_x0, y0 + (header_h - title_font.size) // 2),
|
||||||
y = text_y0 + title_font.size + 12
|
_truncate_to_width(draw, title or "Tasks", title_font, text_w), title_font, BG)
|
||||||
draw.line([(text_x0, y), (text_x0 + text_w, y)], fill=RULE)
|
y = y0 + header_h + 12
|
||||||
y += 12
|
|
||||||
|
|
||||||
row_h = body_font.size + 14
|
row_h = body_font.size + 14
|
||||||
max_rows = max(0, (y0 + h - MARGIN - y) // row_h)
|
max_rows = max(0, (y0 + h - MARGIN - y) // row_h)
|
||||||
|
|
||||||
if not tasks:
|
if not tasks:
|
||||||
draw_text(img, (text_x0, y), "Nothing outstanding", body_font, MUTED)
|
draw_text(img, (text_x0, y), "Nothing outstanding", body_font)
|
||||||
return
|
return
|
||||||
owners_seen: list[str] = []
|
owners_seen: list[str] = []
|
||||||
|
checkbox_fill = panel_style.theme_color("tasks", palette_rgb)
|
||||||
for i, task in enumerate(tasks):
|
for i, task in enumerate(tasks):
|
||||||
if i >= max_rows:
|
if i >= max_rows:
|
||||||
draw_text(img, (text_x0, y), f"+{len(tasks) - max_rows} more", body_font, MUTED)
|
draw_text(img, (text_x0, y), f"+{len(tasks) - max_rows} more", body_font)
|
||||||
break
|
break
|
||||||
done = task.get("completed_at") is not None
|
done = task.get("completed_at") is not None
|
||||||
colors = _event_colors(task, owners_seen, palette_rgb)
|
colors = _event_colors(task, owners_seen, palette_rgb)
|
||||||
_draw_color_bar(draw, text_x0, y + 2, text_x0 + 10, y + row_h - 7, colors, radius=3)
|
panel_style.draw_color_chip(draw, text_x0, y + 2, text_x0 + 10, y + row_h - 7, colors)
|
||||||
box = body_font.size - 6
|
box = body_font.size - 6
|
||||||
box_x = text_x0 + 18
|
box_x = text_x0 + 18
|
||||||
box_y = y + (row_h - box) // 2 - 5
|
box_y = y + (row_h - box) // 2 - 5
|
||||||
|
box_r = min(panel_style.CHIP_RADIUS, box // 2)
|
||||||
if done:
|
if done:
|
||||||
draw.rectangle([box_x, box_y, box_x + box, box_y + box], fill=FG)
|
draw.rounded_rectangle([box_x, box_y, box_x + box, box_y + box], radius=box_r, fill=checkbox_fill)
|
||||||
else:
|
else:
|
||||||
draw.rectangle([box_x, box_y, box_x + box, box_y + box], outline=FG, width=2)
|
draw.rounded_rectangle([box_x, box_y, box_x + box, box_y + box], radius=box_r, outline=FG, width=2)
|
||||||
text_x = box_x + box + 10
|
text_x = box_x + box + 10
|
||||||
due_str = None if done else _fmt_task_due(task.get("due"))
|
due_str = None if done else _fmt_task_due(task.get("due"))
|
||||||
prefix = f"{due_str} " if due_str else ""
|
prefix = f"{due_str} " if due_str else ""
|
||||||
if prefix:
|
if prefix:
|
||||||
draw_text(img, (text_x, y), prefix, body_font, MUTED)
|
draw_text(img, (text_x, y), prefix, body_font)
|
||||||
prefix_w = draw.textlength(prefix, font=body_font) if prefix else 0
|
prefix_w = draw.textlength(prefix, font=body_font) if prefix else 0
|
||||||
_draw_mixed_line(img, draw, (round(text_x + prefix_w), y), task["summary"],
|
_draw_mixed_line(img, draw, (round(text_x + prefix_w), y), task["summary"],
|
||||||
body_font, text_w - (text_x - text_x0) - prefix_w, fill=MUTED if done else FG)
|
body_font, text_w - (text_x - text_x0) - prefix_w)
|
||||||
y += row_h
|
y += row_h
|
||||||
|
|
||||||
|
|
||||||
@@ -512,17 +513,16 @@ _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,
|
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,
|
palette_rgb: list | None = None, weather_cities: list[dict] | None = None,
|
||||||
weather_units: str = "fahrenheit") -> Image.Image:
|
weather_units: str = "fahrenheit") -> Image.Image:
|
||||||
img = Image.new("RGB", (target_w, target_h), BG)
|
img, draw, region = panel_style.card_canvas(target_w, target_h)
|
||||||
draw = ImageDraw.Draw(img)
|
|
||||||
|
|
||||||
title_size, body_size, weather_size = _AGENDA_FONTS[_size_tier(target_w, target_h)]
|
title_size, body_size, weather_size = _AGENDA_FONTS[_size_tier(target_w, target_h)]
|
||||||
title_font = ImageFont.load_default(size=title_size)
|
title_font = panel_style.font_bold(title_size)
|
||||||
body_font = ImageFont.load_default(size=body_size)
|
body_font = panel_style.font_regular(body_size)
|
||||||
weather_font = ImageFont.load_default(size=weather_size)
|
weather_font = panel_style.font_regular(weather_size)
|
||||||
|
|
||||||
day = datetime.now(tz).date() + timedelta(days=browse_offset)
|
day = datetime.now(tz).date() + timedelta(days=browse_offset)
|
||||||
owners_seen: list[str] = []
|
owners_seen: list[str] = []
|
||||||
_draw_agenda_day(img, draw, day, events, tz, (0, 0, target_w, target_h), title_font, body_font, owners_seen,
|
_draw_agenda_day(img, draw, day, events, tz, region, title_font, body_font, owners_seen,
|
||||||
palette_rgb, weather_cities, weather_font, weather_units)
|
palette_rgb, weather_cities, weather_font, weather_units)
|
||||||
|
|
||||||
return img
|
return img
|
||||||
@@ -540,23 +540,22 @@ def _build_today_tomorrow(events: list[dict], browse_offset: int, target_w: int,
|
|||||||
shifts the whole two-day window together, same "days" unit
|
shifts the whole two-day window together, same "days" unit
|
||||||
_build_agenda already uses, so NEXT/BACK behaves identically across
|
_build_agenda already uses, so NEXT/BACK behaves identically across
|
||||||
both views."""
|
both views."""
|
||||||
img = Image.new("RGB", (target_w, target_h), BG)
|
img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h)
|
||||||
draw = ImageDraw.Draw(img)
|
|
||||||
|
|
||||||
title_size, body_size, weather_size = _TODAY_TOMORROW_FONTS[_size_tier(target_w, target_h)]
|
title_size, body_size, weather_size = _TODAY_TOMORROW_FONTS[_size_tier(target_w, target_h)]
|
||||||
title_font = ImageFont.load_default(size=title_size)
|
title_font = panel_style.font_bold(title_size)
|
||||||
body_font = ImageFont.load_default(size=body_size)
|
body_font = panel_style.font_regular(body_size)
|
||||||
weather_font = ImageFont.load_default(size=weather_size)
|
weather_font = panel_style.font_regular(weather_size)
|
||||||
|
|
||||||
start_day = datetime.now(tz).date() + timedelta(days=browse_offset)
|
start_day = datetime.now(tz).date() + timedelta(days=browse_offset)
|
||||||
section_h = target_h // 2
|
section_h = ch // 2
|
||||||
owners_seen: list[str] = []
|
owners_seen: list[str] = []
|
||||||
for i in range(2):
|
for i in range(2):
|
||||||
section_y0 = i * section_h
|
section_y0 = cy0 + i * section_h
|
||||||
if i > 0:
|
if i > 0:
|
||||||
draw.line([(MARGIN, section_y0), (target_w - MARGIN, section_y0)], fill=RULE)
|
draw.line([(cx0 + MARGIN, section_y0), (cx0 + cw - MARGIN, section_y0)], fill=RULE)
|
||||||
_draw_agenda_day(img, draw, start_day + timedelta(days=i), events, tz,
|
_draw_agenda_day(img, draw, start_day + timedelta(days=i), events, tz,
|
||||||
(0, section_y0, target_w, section_h), title_font, body_font, owners_seen,
|
(cx0, section_y0, cw, section_h), title_font, body_font, owners_seen,
|
||||||
palette_rgb, weather_cities, weather_font, weather_units)
|
palette_rgb, weather_cities, weather_font, weather_units)
|
||||||
|
|
||||||
return img
|
return img
|
||||||
@@ -585,8 +584,7 @@ def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
|
|||||||
otherwise "start of the week" doesn't mean much for an arbitrary day
|
otherwise "start of the week" doesn't mean much for an arbitrary day
|
||||||
count, so it instead starts `start_offset` days from today (0 =
|
count, so it instead starts `start_offset` days from today (0 =
|
||||||
today, see routers/api_widgets.py's api_widget_config_save)."""
|
today, see routers/api_widgets.py's api_widget_config_save)."""
|
||||||
img = Image.new("RGB", (target_w, target_h), BG)
|
img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h)
|
||||||
draw = ImageDraw.Draw(img)
|
|
||||||
tier = _size_tier(target_w, target_h)
|
tier = _size_tier(target_w, target_h)
|
||||||
|
|
||||||
today = datetime.now(tz).date()
|
today = datetime.now(tz).date()
|
||||||
@@ -599,36 +597,36 @@ def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
|
|||||||
|
|
||||||
if layout == "vertical":
|
if layout == "vertical":
|
||||||
title_base, body_base, weather_base = _WEEK_VERTICAL_FONTS[tier]
|
title_base, body_base, weather_base = _WEEK_VERTICAL_FONTS[tier]
|
||||||
title_font = ImageFont.load_default(size=max(14, title_base - days))
|
title_font = panel_style.font_bold(max(14, title_base - days))
|
||||||
body_font = ImageFont.load_default(size=max(11, body_base - days))
|
body_font = panel_style.font_regular(max(11, body_base - days))
|
||||||
weather_font = ImageFont.load_default(size=max(9, weather_base - days))
|
weather_font = panel_style.font_regular(max(9, weather_base - days))
|
||||||
section_h = target_h // days
|
section_h = ch // days
|
||||||
for i in range(days):
|
for i in range(days):
|
||||||
section_y0 = i * section_h
|
section_y0 = cy0 + i * section_h
|
||||||
if i > 0:
|
if i > 0:
|
||||||
draw.line([(MARGIN, section_y0), (target_w - MARGIN, section_y0)], fill=RULE)
|
draw.line([(cx0 + MARGIN, section_y0), (cx0 + cw - MARGIN, section_y0)], fill=RULE)
|
||||||
day = week_first_day + timedelta(days=i)
|
day = week_first_day + timedelta(days=i)
|
||||||
_draw_agenda_day(img, draw, day, events, tz, (0, section_y0, target_w, section_h),
|
_draw_agenda_day(img, draw, day, events, tz, (cx0, section_y0, cw, section_h),
|
||||||
title_font, body_font, owners_seen, palette_rgb,
|
title_font, body_font, owners_seen, palette_rgb,
|
||||||
weather_cities, weather_font, weather_units)
|
weather_cities, weather_font, weather_units)
|
||||||
return img
|
return img
|
||||||
|
|
||||||
header_size, chip_size, weather_size = _WEEK_HORIZONTAL_FONTS[tier]
|
header_size, chip_size, weather_size = _WEEK_HORIZONTAL_FONTS[tier]
|
||||||
header_font = ImageFont.load_default(size=header_size)
|
header_font = panel_style.font_bold(header_size)
|
||||||
chip_font = ImageFont.load_default(size=chip_size)
|
chip_font = panel_style.font_regular(chip_size)
|
||||||
weather_font = ImageFont.load_default(size=weather_size)
|
weather_font = panel_style.font_regular(weather_size)
|
||||||
col_w = (target_w - MARGIN * 2) // days
|
col_w = (cw - MARGIN * 2) // days
|
||||||
header_h = 44
|
header_h = 44
|
||||||
|
|
||||||
for col in range(days):
|
for col in range(days):
|
||||||
day = week_first_day + timedelta(days=col)
|
day = week_first_day + timedelta(days=col)
|
||||||
x0 = MARGIN + col * col_w
|
x0 = cx0 + MARGIN + col * col_w
|
||||||
if col > 0:
|
if col > 0:
|
||||||
draw.line([(x0, MARGIN), (x0, target_h - MARGIN)], fill=RULE)
|
draw.line([(x0, cy0 + MARGIN), (x0, cy0 + ch - MARGIN)], fill=RULE)
|
||||||
label = day.strftime("%a %-d") if day != today else f"* {day.strftime('%a %-d')}"
|
label = day.strftime("%a %-d") if day != today else f"* {day.strftime('%a %-d')}"
|
||||||
draw_text(img, (x0 + 6, MARGIN), _truncate_to_width(draw, label, header_font, col_w - 10), header_font)
|
draw_text(img, (x0 + 6, cy0 + MARGIN), _truncate_to_width(draw, label, header_font, col_w - 10), header_font)
|
||||||
|
|
||||||
y = MARGIN + header_h
|
y = cy0 + MARGIN + header_h
|
||||||
# Columns are narrow, so only what actually fits gets drawn (see
|
# Columns are narrow, so only what actually fits gets drawn (see
|
||||||
# weather_render.draw_weather_row) -- typically one city, no label
|
# weather_render.draw_weather_row) -- typically one city, no label
|
||||||
# (the column itself makes which day it's for obvious; a city name
|
# (the column itself makes which day it's for obvious; a city name
|
||||||
@@ -640,14 +638,14 @@ def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
|
|||||||
icon_r=8, font=weather_font, units=weather_units, show_labels=False,
|
icon_r=8, font=weather_font, units=weather_units, show_labels=False,
|
||||||
palette_rgb=palette_rgb)
|
palette_rgb=palette_rgb)
|
||||||
row_h = chip_font.size + 10
|
row_h = chip_font.size + 10
|
||||||
max_rows = max(0, (target_h - MARGIN - y) // row_h)
|
max_rows = max(0, (cy0 + ch - MARGIN - y) // row_h)
|
||||||
day_events = _events_on_day(events, day, tz)
|
day_events = _events_on_day(events, day, tz)
|
||||||
for i, event in enumerate(day_events):
|
for i, event in enumerate(day_events):
|
||||||
if i >= max_rows:
|
if i >= max_rows:
|
||||||
draw_text(img, (x0 + 6, y), f"+{len(day_events) - max_rows}", chip_font, MUTED)
|
draw_text(img, (x0 + 6, y), f"+{len(day_events) - max_rows}", chip_font)
|
||||||
break
|
break
|
||||||
colors = _event_colors(event, owners_seen, palette_rgb)
|
colors = _event_colors(event, owners_seen, palette_rgb)
|
||||||
_draw_color_bar(draw, x0 + 4, y + 1, x0 + 11, y + row_h - 5, colors, radius=2)
|
panel_style.draw_color_chip(draw, x0 + 4, y + 1, x0 + 11, y + row_h - 5, colors, radius=2)
|
||||||
if event["all_day"]:
|
if event["all_day"]:
|
||||||
_draw_mixed_line(img, draw, (x0 + 16, y), event["summary"], chip_font, col_w - 20)
|
_draw_mixed_line(img, draw, (x0 + 16, y), event["summary"], chip_font, col_w - 20)
|
||||||
else:
|
else:
|
||||||
@@ -672,39 +670,60 @@ def _build_month(events: list[dict], browse_offset: int, target_w: int, target_h
|
|||||||
week_start: int, palette_rgb: list | None = None) -> Image.Image:
|
week_start: int, palette_rgb: list | None = None) -> Image.Image:
|
||||||
"""Density dots per day, not literal event text -- real text at
|
"""Density dots per day, not literal event text -- real text at
|
||||||
typical month-cell size (~100x70px) is close to unreadable on a
|
typical month-cell size (~100x70px) is close to unreadable on a
|
||||||
6-color dithered e-ink panel. Capped at 4 visible dots, "+N" beyond."""
|
6-color dithered e-ink panel. Capped at 4 visible dots, "+N" beyond.
|
||||||
img = Image.new("RGB", (target_w, target_h), BG)
|
"Not in this month" day numbers used to be a muted gray -- now
|
||||||
draw = ImageDraw.Draw(img)
|
de-emphasized by weight instead (Regular vs. Bold), same reasoning
|
||||||
|
as everywhere else this module dropped MUTED -- see module-level
|
||||||
|
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 = _MONTH_FONTS[_size_tier(target_w, target_h)]
|
||||||
header_font = ImageFont.load_default(size=header_size)
|
header_font = panel_style.font_bold(header_size)
|
||||||
day_font = ImageFont.load_default(size=day_size)
|
day_font_in_month = panel_style.font_bold(day_size)
|
||||||
|
day_font_out_of_month = panel_style.font_regular(day_size)
|
||||||
|
|
||||||
today = datetime.now(tz).date()
|
today = datetime.now(tz).date()
|
||||||
target_month = _add_months(date(today.year, today.month, 1), browse_offset)
|
target_month = _add_months(date(today.year, today.month, 1), browse_offset)
|
||||||
weeks = list(calendar_module.Calendar(firstweekday=week_start).monthdatescalendar(target_month.year, target_month.month))
|
weeks = list(calendar_module.Calendar(firstweekday=week_start).monthdatescalendar(target_month.year, target_month.month))
|
||||||
|
|
||||||
col_w = (target_w - MARGIN * 2) // 7
|
col_w = (cw - MARGIN * 2) // 7
|
||||||
header_h = 28
|
header_h = 28
|
||||||
grid_top = MARGIN + header_h
|
grid_top = cy0 + MARGIN + header_h
|
||||||
row_h = (target_h - MARGIN - grid_top) // len(weeks)
|
row_h = (cy0 + ch - MARGIN - grid_top) // len(weeks)
|
||||||
|
today_accent = panel_style.theme_color("calendar", palette_rgb)
|
||||||
|
today_badge_r = min(panel_style.CHIP_RADIUS, 9)
|
||||||
|
|
||||||
day_names = WEEKDAY_NAMES[week_start:] + WEEKDAY_NAMES[:week_start]
|
day_names = WEEKDAY_NAMES[week_start:] + WEEKDAY_NAMES[:week_start]
|
||||||
for col, name in enumerate(day_names):
|
for col, name in enumerate(day_names):
|
||||||
draw_text(img, (MARGIN + col * col_w + 6, MARGIN), name[:3], header_font, MUTED)
|
draw_text(img, (cx0 + MARGIN + col * col_w + 6, cy0 + MARGIN), name[:3], header_font)
|
||||||
|
|
||||||
owners_seen: list[str] = []
|
owners_seen: list[str] = []
|
||||||
dot_r = 6
|
dot_r = 6
|
||||||
for row, week in enumerate(weeks):
|
for row, week in enumerate(weeks):
|
||||||
for col, day in enumerate(week):
|
for col, day in enumerate(week):
|
||||||
x0 = MARGIN + col * col_w
|
x0 = cx0 + MARGIN + col * col_w
|
||||||
y0 = grid_top + row * row_h
|
y0 = grid_top + row * row_h
|
||||||
draw.rectangle([x0, y0, x0 + col_w, y0 + row_h], outline=RULE)
|
draw.rectangle([x0, y0, x0 + col_w, y0 + row_h], outline=RULE)
|
||||||
in_month = day.month == target_month.month
|
in_month = day.month == target_month.month
|
||||||
text_color = FG if in_month else MUTED
|
|
||||||
if day == today:
|
if day == today:
|
||||||
draw.rectangle([x0 + 2, y0 + 2, x0 + 24, y0 + 20], outline=FG)
|
# A filled accent badge (this widget's own theme color,
|
||||||
draw_text(img, (x0 + 6, y0 + 4), str(day.day), day_font, text_color)
|
# see panel_style.THEME) instead of the old bare outline
|
||||||
|
# -- an actual "today" indicator, not just an outline
|
||||||
|
# easy to miss at ~24px. Sized around the actual digit
|
||||||
|
# bbox (not a fixed pixel box) so a bold 2-digit day
|
||||||
|
# number ("30") fits as comfortably as a single digit
|
||||||
|
# ("3") at every size tier.
|
||||||
|
day_str = str(day.day)
|
||||||
|
text_x, text_y = x0 + 6, y0 + 4
|
||||||
|
dbbox = draw.textbbox((text_x, text_y), day_str, font=day_font_in_month)
|
||||||
|
pad = 3
|
||||||
|
badge_rect = [dbbox[0] - pad, dbbox[1] - pad, dbbox[2] + pad, dbbox[3] + pad]
|
||||||
|
badge_r = min(today_badge_r, (badge_rect[3] - badge_rect[1]) // 2)
|
||||||
|
draw.rounded_rectangle(badge_rect, radius=badge_r, fill=today_accent)
|
||||||
|
draw_text(img, (text_x, text_y), day_str, day_font_in_month, BG)
|
||||||
|
else:
|
||||||
|
day_font = day_font_in_month if in_month else day_font_out_of_month
|
||||||
|
draw_text(img, (x0 + 6, y0 + 4), str(day.day), day_font)
|
||||||
|
|
||||||
day_events = _events_on_day(events, day, tz)
|
day_events = _events_on_day(events, day, tz)
|
||||||
dot_x = x0 + 8
|
dot_x = x0 + 8
|
||||||
@@ -718,7 +737,7 @@ def _build_month(events: list[dict], browse_offset: int, target_w: int, target_h
|
|||||||
draw.ellipse([dot_x, dot_y, dot_x + dot_r * 2, dot_y + dot_r * 2], fill=event_color)
|
draw.ellipse([dot_x, dot_y, dot_x + dot_r * 2, dot_y + dot_r * 2], fill=event_color)
|
||||||
dot_x += dot_r * 2 + 5
|
dot_x += dot_r * 2 + 5
|
||||||
if len(day_events) > 4:
|
if len(day_events) > 4:
|
||||||
draw_text(img, (dot_x, dot_y - 2), f"+{len(day_events) - 4}", header_font, MUTED)
|
draw_text(img, (dot_x, dot_y - 2), f"+{len(day_events) - 4}", header_font)
|
||||||
|
|
||||||
return img
|
return img
|
||||||
|
|
||||||
@@ -759,8 +778,13 @@ def _build(events: list[dict], view: str, browse_offset: int, target_w: int, tar
|
|||||||
weather_cities, weather_units)
|
weather_cities, weather_units)
|
||||||
|
|
||||||
if fetch_summary:
|
if fetch_summary:
|
||||||
font = ImageFont.load_default(size=14 if _size_tier(target_w, target_h) != "small" else 11)
|
# Drawn as a final overlay onto the already-composited img (not
|
||||||
draw_text(img, (MARGIN, target_h - MARGIN - font.size), fetch_summary, font, MUTED)
|
# inside any one _build_* branch above), so it offsets by
|
||||||
|
# panel_style.GUTTER itself to land inside the same visible
|
||||||
|
# margin every builder's own content already respects.
|
||||||
|
font = panel_style.font_regular(14 if _size_tier(target_w, target_h) != "small" else 11)
|
||||||
|
draw_text(img, (panel_style.GUTTER + MARGIN, target_h - panel_style.GUTTER - MARGIN - font.size),
|
||||||
|
fetch_summary, font)
|
||||||
|
|
||||||
return img
|
return img
|
||||||
|
|
||||||
@@ -815,12 +839,11 @@ def _build_tasks(tasks: list[dict], target_w: int, target_h: int, palette_rgb: l
|
|||||||
"""A tasks widget's entire region is the checklist -- unlike the old
|
"""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,
|
week-view slot, there's no day columns/header to share space with,
|
||||||
so this is just _draw_tasks over the whole box."""
|
so this is just _draw_tasks over the whole box."""
|
||||||
img = Image.new("RGB", (target_w, target_h), BG)
|
img, draw, region = panel_style.card_canvas(target_w, target_h)
|
||||||
draw = ImageDraw.Draw(img)
|
|
||||||
title_size, body_size = _TASKS_FONTS[_size_tier(target_w, target_h)]
|
title_size, body_size = _TASKS_FONTS[_size_tier(target_w, target_h)]
|
||||||
title_font = ImageFont.load_default(size=title_size)
|
title_font = panel_style.font_bold(title_size)
|
||||||
body_font = ImageFont.load_default(size=body_size)
|
body_font = panel_style.font_regular(body_size)
|
||||||
_draw_tasks(img, draw, (0, 0, target_w, target_h), tasks, title_font, body_font, palette_rgb, title)
|
_draw_tasks(img, draw, region, tasks, title_font, body_font, palette_rgb, title)
|
||||||
return img
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,327 @@
|
|||||||
|
"""Experimental "modern" weather widget render style: Jinja2 + a
|
||||||
|
persistent headless Chromium browser (Playwright) instead of the hand-
|
||||||
|
drawn PIL primitives in weather_render.py -- see docs/widgets.md and the
|
||||||
|
`html-widget-render` branch's PR description for the design rationale
|
||||||
|
(gradients/shadows/soft shading that PIL can't easily do, at the cost of
|
||||||
|
a real browser-process dependency).
|
||||||
|
|
||||||
|
Two things this module owns that nothing else in the codebase needed
|
||||||
|
before:
|
||||||
|
|
||||||
|
1. A **persistent** background browser process. Widget rendering already
|
||||||
|
happens concurrently across a fresh `ThreadPoolExecutor` per frame
|
||||||
|
request (routers/device.py's _render_widgets) -- Playwright's sync
|
||||||
|
API is thread-affine (an object must be used from the thread that
|
||||||
|
created it), so a single browser object can't be handed across those
|
||||||
|
ad-hoc worker threads, and relaunching a full Chromium process on
|
||||||
|
every widget render would be real, avoidable latency. Fix: one
|
||||||
|
background thread runs its own persistent asyncio event loop hosting
|
||||||
|
one long-lived `Browser`, lazily started on first use (see start()) --
|
||||||
|
not eagerly at server startup, so a deployment that never enables the
|
||||||
|
weather widget's "modern" style never launches Chromium at all and
|
||||||
|
never needs Playwright's browser binaries installed. main.py's
|
||||||
|
lifespan only wires up the *shutdown* half (stop()), so a clean
|
||||||
|
server restart doesn't leave an orphaned Chromium process behind if
|
||||||
|
this was ever actually used. render_html_to_image() is a plain sync
|
||||||
|
function any worker thread can call, bridging in via
|
||||||
|
`asyncio.run_coroutine_threadsafe` (the standard safe cross-thread
|
||||||
|
entry point into a *running* loop on another thread).
|
||||||
|
|
||||||
|
2. **Per-region ordered (Bayer) dithering against the palette**, done
|
||||||
|
here rather than in the shared image_pipeline.py pipeline.
|
||||||
|
render_panel's whole-canvas single Floyd-Steinberg pass exists
|
||||||
|
because Floyd-Steinberg's error diffusion can't be split across
|
||||||
|
independently-quantized regions without a visible seam at the
|
||||||
|
boundary -- but that reasoning doesn't apply to ordered dithering,
|
||||||
|
which has no cross-pixel error term (each pixel's dither decision
|
||||||
|
only depends on its own position + color). So this module dithers its
|
||||||
|
own rendered widget to *already-exact* palette colors before
|
||||||
|
returning it; the later shared Floyd-Steinberg pass sees zero
|
||||||
|
quantization error there and leaves it untouched -- the same
|
||||||
|
"pre-commit to exact palette colors" trick image_pipeline.draw_text
|
||||||
|
and the hand-drawn weather icons already rely on, just reached a
|
||||||
|
different way. Floyd-Steinberg keeps working exactly as before for
|
||||||
|
photos and every other (classic-rendered) widget region.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import io
|
||||||
|
import threading
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from . import panel_style
|
||||||
|
from .image_pipeline import DEFAULT_PALETTE_RGB
|
||||||
|
|
||||||
|
_TEMPLATE_DIR = Path(__file__).resolve().parent / "templates" / "widget_html"
|
||||||
|
_FONT_DIR = Path(__file__).resolve().parent / "fonts"
|
||||||
|
|
||||||
|
_jinja_env = Environment(
|
||||||
|
loader=FileSystemLoader(str(_TEMPLATE_DIR)),
|
||||||
|
autoescape=select_autoescape(["html", "jinja"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
CATEGORY_EMOJI = {
|
||||||
|
"clear": "☀️",
|
||||||
|
"partly_cloudy": "⛅",
|
||||||
|
"cloudy": "☁️",
|
||||||
|
"fog": "\U0001f32b️",
|
||||||
|
"rain": "\U0001f327️",
|
||||||
|
"snow": "❄️",
|
||||||
|
"thunderstorm": "⛈️",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ACCENT_START/END: a fixed blue gradient pair for the "modern" style's
|
||||||
|
# card header -- deliberately not routed through panel_style.theme_color
|
||||||
|
# (unlike every classic-rendered widget's chrome), since the whole point
|
||||||
|
# of this style is the gradient look ordered_dither below then commits
|
||||||
|
# to exact palette colors anyway; which literal hex this starts from
|
||||||
|
# doesn't matter to the end result the way it would for a flat PIL fill.
|
||||||
|
ACCENT_START = "#1c4fd6"
|
||||||
|
ACCENT_END = "#6fa8ff"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Persistent background browser -------------------------------------
|
||||||
|
|
||||||
|
_loop: asyncio.AbstractEventLoop | None = None
|
||||||
|
_loop_thread: threading.Thread | None = None
|
||||||
|
_browser = None
|
||||||
|
_playwright_cm = None
|
||||||
|
_start_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
async def _launch_browser() -> None:
|
||||||
|
global _browser, _playwright_cm
|
||||||
|
from playwright.async_api import async_playwright
|
||||||
|
|
||||||
|
_playwright_cm = async_playwright()
|
||||||
|
playwright = await _playwright_cm.__aenter__()
|
||||||
|
_browser = await playwright.chromium.launch()
|
||||||
|
|
||||||
|
|
||||||
|
async def _close_browser() -> None:
|
||||||
|
global _browser, _playwright_cm
|
||||||
|
if _browser is not None:
|
||||||
|
await _browser.close()
|
||||||
|
_browser = None
|
||||||
|
if _playwright_cm is not None:
|
||||||
|
await _playwright_cm.__aexit__(None, None, None)
|
||||||
|
_playwright_cm = None
|
||||||
|
|
||||||
|
|
||||||
|
def start() -> None:
|
||||||
|
"""Launches the background event loop + persistent Chromium browser,
|
||||||
|
if not already running. Called lazily by render_html_to_image on
|
||||||
|
first use (not from main.py's lifespan -- see module docstring for
|
||||||
|
why this must stay opt-in) -- exposed directly too, for tests that
|
||||||
|
want to control startup explicitly. Idempotent -- a second call
|
||||||
|
while already started is a no-op."""
|
||||||
|
global _loop, _loop_thread
|
||||||
|
if _loop is not None:
|
||||||
|
return
|
||||||
|
ready = threading.Event()
|
||||||
|
|
||||||
|
def _run() -> None:
|
||||||
|
global _loop
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
_loop = loop
|
||||||
|
ready.set()
|
||||||
|
loop.run_forever()
|
||||||
|
|
||||||
|
_loop_thread = threading.Thread(target=_run, daemon=True, name="html-render-loop")
|
||||||
|
_loop_thread.start()
|
||||||
|
ready.wait()
|
||||||
|
asyncio.run_coroutine_threadsafe(_launch_browser(), _loop).result()
|
||||||
|
|
||||||
|
|
||||||
|
def stop() -> None:
|
||||||
|
"""Closes the browser and stops the background loop -- called from
|
||||||
|
main.py's lifespan shutdown so a server restart never leaves an
|
||||||
|
orphaned Chromium process behind. No-op if start() was never called
|
||||||
|
(the common case: most deployments never enable "modern" style)."""
|
||||||
|
global _loop, _loop_thread
|
||||||
|
if _loop is None:
|
||||||
|
return
|
||||||
|
asyncio.run_coroutine_threadsafe(_close_browser(), _loop).result()
|
||||||
|
_loop.call_soon_threadsafe(_loop.stop)
|
||||||
|
_loop_thread.join(timeout=5)
|
||||||
|
_loop = None
|
||||||
|
_loop_thread = None
|
||||||
|
|
||||||
|
|
||||||
|
async def _screenshot(html: str, target_w: int, target_h: int) -> bytes:
|
||||||
|
page = await _browser.new_page(viewport={"width": target_w, "height": target_h}, device_scale_factor=1)
|
||||||
|
try:
|
||||||
|
await page.set_content(html, wait_until="networkidle")
|
||||||
|
return await page.screenshot()
|
||||||
|
finally:
|
||||||
|
await page.close()
|
||||||
|
|
||||||
|
|
||||||
|
def render_html_to_image(html: str, target_w: int, target_h: int) -> Image.Image:
|
||||||
|
"""Renders `html` (already sized to target_w x target_h via its own
|
||||||
|
<style>) through the persistent headless Chromium browser and
|
||||||
|
returns an RGB image of exactly that size. Safe to call from any
|
||||||
|
thread -- bridges into the dedicated background asyncio loop via
|
||||||
|
run_coroutine_threadsafe. Lazily calls start() on first use (see its
|
||||||
|
docstring) -- the first "modern" style render on a freshly-started
|
||||||
|
server pays Chromium's launch latency; every render after that reuses
|
||||||
|
the same persistent browser."""
|
||||||
|
if _loop is None:
|
||||||
|
with _start_lock:
|
||||||
|
if _loop is None:
|
||||||
|
start()
|
||||||
|
future = asyncio.run_coroutine_threadsafe(_screenshot(html, target_w, target_h), _loop)
|
||||||
|
png_bytes = future.result()
|
||||||
|
return Image.open(io.BytesIO(png_bytes)).convert("RGB")
|
||||||
|
|
||||||
|
|
||||||
|
# --- Ordered (Bayer 8x8) dithering against an arbitrary palette ---------
|
||||||
|
|
||||||
|
_BAYER8 = (
|
||||||
|
np.array(
|
||||||
|
[
|
||||||
|
[0, 32, 8, 40, 2, 34, 10, 42],
|
||||||
|
[48, 16, 56, 24, 50, 18, 58, 26],
|
||||||
|
[12, 44, 4, 36, 14, 46, 6, 38],
|
||||||
|
[60, 28, 52, 20, 62, 30, 54, 22],
|
||||||
|
[3, 35, 11, 43, 1, 33, 9, 41],
|
||||||
|
[51, 19, 59, 27, 49, 17, 57, 25],
|
||||||
|
[15, 47, 7, 39, 13, 45, 5, 37],
|
||||||
|
[63, 31, 55, 23, 61, 29, 53, 21],
|
||||||
|
],
|
||||||
|
dtype=np.float32,
|
||||||
|
)
|
||||||
|
/ 64.0
|
||||||
|
- 0.5
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ordered_dither(img: Image.Image, palette_rgb: list | None, amplitude: float = 48.0) -> Image.Image:
|
||||||
|
"""Bayer-ordered dither of `img` against `palette_rgb` (falls back to
|
||||||
|
DEFAULT_PALETTE_RGB) -- every output pixel is one of the palette's
|
||||||
|
exact colors, spatially patterned rather than error-diffused, so it's
|
||||||
|
safe to run per-region before compositing (see module docstring for
|
||||||
|
why that's not true of Floyd-Steinberg). `amplitude` is the Bayer
|
||||||
|
bias's full swing in 0-255 RGB units before nearest-palette-color
|
||||||
|
matching -- 48 was the value this render style was tuned against in
|
||||||
|
the exploratory spike behind this feature; not exposed as a per-frame
|
||||||
|
setting (unlike dither_strength) since there's only one consumer of
|
||||||
|
it today."""
|
||||||
|
palette = np.array(palette_rgb or DEFAULT_PALETTE_RGB, dtype=np.float32)
|
||||||
|
arr = np.asarray(img.convert("RGB"), dtype=np.float32)
|
||||||
|
h, w, _ = arr.shape
|
||||||
|
tile = np.tile(_BAYER8, (h // 8 + 1, w // 8 + 1))[:h, :w]
|
||||||
|
biased = np.clip(arr + tile[:, :, None] * amplitude, 0, 255)
|
||||||
|
diffs = biased[:, :, None, :] - palette[None, None, :, :]
|
||||||
|
dists = np.einsum("hwkc,hwkc->hwk", diffs, diffs)
|
||||||
|
idx = np.argmin(dists, axis=2)
|
||||||
|
return Image.fromarray(palette[idx].astype(np.uint8), "RGB")
|
||||||
|
|
||||||
|
|
||||||
|
# --- Weather "modern" style ----------------------------------------------
|
||||||
|
|
||||||
|
def _day_label(day_date: date) -> str:
|
||||||
|
delta = (day_date - date.today()).days
|
||||||
|
if delta == 0:
|
||||||
|
return "Today"
|
||||||
|
if delta == 1:
|
||||||
|
return "Tomorrow"
|
||||||
|
return day_date.strftime("%a")
|
||||||
|
|
||||||
|
|
||||||
|
def build_current(entry: dict | None, target_w: int, target_h: int, palette_rgb: list | None = None,
|
||||||
|
units: str = "fahrenheit", city_label: str = "") -> Image.Image:
|
||||||
|
"""HTML/CSS-rendered analogue of weather_render.build_current --
|
||||||
|
same call signature, so app/widgets/weather.py can dispatch to
|
||||||
|
either interchangeably. Returns an already-palette-exact RGB image
|
||||||
|
(see ordered_dither)."""
|
||||||
|
img = Image.new("RGB", (target_w, target_h), (255, 255, 255))
|
||||||
|
if not entry:
|
||||||
|
return img
|
||||||
|
|
||||||
|
unit_suffix = "F" if units == "fahrenheit" else "C"
|
||||||
|
icon_size = max(28, min(target_w, target_h) // 3)
|
||||||
|
template = _jinja_env.get_template("weather_current.html.jinja")
|
||||||
|
html = template.render(
|
||||||
|
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=panel_style.CARD_RADIUS,
|
||||||
|
font_dir=str(_FONT_DIR), emoji=CATEGORY_EMOJI.get(entry["category"], ""),
|
||||||
|
temp=round(entry["temp"]), unit_suffix=unit_suffix, city_label=city_label,
|
||||||
|
icon_size=icon_size, temp_size=max(24, min(target_w, target_h) // 3),
|
||||||
|
label_size=max(12, icon_size // 3),
|
||||||
|
)
|
||||||
|
rendered = render_html_to_image(html, target_w, target_h)
|
||||||
|
return ordered_dither(rendered, palette_rgb)
|
||||||
|
|
||||||
|
|
||||||
|
def build_daily(daily: dict[str, dict], target_w: int, target_h: int, palette_rgb: list | None = None,
|
||||||
|
units: str = "fahrenheit", city_label: str = "") -> Image.Image:
|
||||||
|
"""HTML/CSS-rendered analogue of weather_render.build_daily -- same
|
||||||
|
call signature. Returns an already-palette-exact RGB image (see
|
||||||
|
ordered_dither)."""
|
||||||
|
img = Image.new("RGB", (target_w, target_h), (255, 255, 255))
|
||||||
|
days = list(daily.items())
|
||||||
|
if not days:
|
||||||
|
return img
|
||||||
|
|
||||||
|
unit_suffix = "F" if units == "fahrenheit" else "C"
|
||||||
|
header_h = max(28, min(target_w, target_h) // 8) if city_label else 0
|
||||||
|
col_w = max(1, target_w // len(days))
|
||||||
|
icon_size = max(16, min(col_w // 2, 36))
|
||||||
|
day_entries = [
|
||||||
|
{
|
||||||
|
"label": _day_label(date.fromisoformat(day_str)),
|
||||||
|
"emoji": CATEGORY_EMOJI.get(d["category"], ""),
|
||||||
|
"high": round(d["high"]),
|
||||||
|
"low": round(d["low"]),
|
||||||
|
}
|
||||||
|
for day_str, d in days
|
||||||
|
]
|
||||||
|
template = _jinja_env.get_template("weather_daily.html.jinja")
|
||||||
|
html = template.render(
|
||||||
|
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=panel_style.CARD_RADIUS,
|
||||||
|
font_dir=str(_FONT_DIR), city_label=city_label, header_h=header_h,
|
||||||
|
title_size=max(14, header_h - 12), accent_start=ACCENT_START, accent_end=ACCENT_END,
|
||||||
|
days=day_entries, icon_size=icon_size, label_size=max(12, icon_size // 2),
|
||||||
|
unit_suffix=unit_suffix,
|
||||||
|
)
|
||||||
|
rendered = render_html_to_image(html, target_w, target_h)
|
||||||
|
return ordered_dither(rendered, palette_rgb)
|
||||||
|
|
||||||
|
|
||||||
|
SUPPORTED_MODES = ("current", "daily")
|
||||||
|
|
||||||
|
|
||||||
|
def build(mode: str, data, target_w: int, target_h: int, palette_rgb: list | None = None,
|
||||||
|
units: str = "fahrenheit", city_label: str = "") -> Image.Image:
|
||||||
|
"""Dispatches to build_current/build_daily -- mirrors weather_render.
|
||||||
|
build()'s signature (minus interval_hours, which no modern-style mode
|
||||||
|
uses) so app/widgets/weather.py and the weather preview endpoint can
|
||||||
|
call either module identically. Only call this for mode in
|
||||||
|
SUPPORTED_MODES -- callers are expected to have already fallen back to
|
||||||
|
weather_render.build() for hourly/multi_city (see weather.py)."""
|
||||||
|
if mode == "current":
|
||||||
|
return build_current(data, target_w, target_h, palette_rgb, units, city_label)
|
||||||
|
return build_daily(data, target_w, target_h, palette_rgb, units, city_label)
|
||||||
|
|
||||||
|
|
||||||
|
def render_weather_preview_png(mode: str, data, orientation: str, palette_rgb: list | None,
|
||||||
|
units: str = "fahrenheit", city_label: str = "") -> bytes:
|
||||||
|
"""Modern-style analogue of weather_render.render_weather_preview_png
|
||||||
|
-- same browser-viewable-PNG convention every other widget's preview
|
||||||
|
endpoint uses. build()'s output is already palette-exact (see
|
||||||
|
ordered_dither), so the final _quantize pass here is a no-op on it,
|
||||||
|
same reasoning as the module docstring's compositing story."""
|
||||||
|
from .image_pipeline import _quantize, _png_bytes, logical_render_size
|
||||||
|
|
||||||
|
target_w, target_h = logical_render_size(orientation)
|
||||||
|
img = build(mode, data, target_w, target_h, palette_rgb, units, city_label)
|
||||||
|
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
|
||||||
|
return _png_bytes(quantized)
|
||||||
+79
-187
@@ -5,14 +5,13 @@ from __future__ import annotations
|
|||||||
import io
|
import io
|
||||||
import math
|
import math
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
from PIL import Image, ImageDraw, ImageEnhance, ImageFont, ImageOps
|
from PIL import Image, ImageDraw, ImageEnhance, ImageFont, ImageOps
|
||||||
|
|
||||||
EPD_WIDTH = 800
|
EPD_WIDTH = 800
|
||||||
EPD_HEIGHT = 480
|
EPD_HEIGHT = 480
|
||||||
|
|
||||||
# PIL's TrueType rendering antialiases by default (graduated gray edge
|
# PIL's TrueType rendering antialiases by default (graduated gray edge
|
||||||
# pixels). Those survive straight into _quantize's error-diffusion
|
# pixels). Those survive straight into _quantize's Floyd-Steinberg
|
||||||
# dithering, which -- confirmed visually -- turns them into scattered
|
# dithering, which -- confirmed visually -- turns them into scattered
|
||||||
# colored speckles along every glyph edge once forced onto the panel's 6
|
# colored speckles along every glyph edge once forced onto the panel's 6
|
||||||
# colors, since a mid-gray input has no close palette match and the
|
# colors, since a mid-gray input has no close palette match and the
|
||||||
@@ -62,7 +61,8 @@ def _dotted_edge(draw: ImageDraw.ImageDraw, x0: float, y0: float, x1: float, y1:
|
|||||||
pos += spacing
|
pos += spacing
|
||||||
|
|
||||||
|
|
||||||
def draw_widget_border(img: Image.Image, style: str, thickness: int, color: tuple[int, int, int]) -> None:
|
def draw_widget_border(img: Image.Image, style: str, thickness: int, color: tuple[int, int, int],
|
||||||
|
radius: int = 0) -> None:
|
||||||
"""Draws a border inset within img's own bounds, mutating it in
|
"""Draws a border inset within img's own bounds, mutating it in
|
||||||
place -- called once per widget's own region (routers/device.py's
|
place -- called once per widget's own region (routers/device.py's
|
||||||
_render_widgets, and each widget type's own dialog preview) before
|
_render_widgets, and each widget type's own dialog preview) before
|
||||||
@@ -75,24 +75,44 @@ def draw_widget_border(img: Image.Image, style: str, thickness: int, color: tupl
|
|||||||
"solid"/"dashed"/"dotted" are a single thickness-px stroke traced
|
"solid"/"dashed"/"dotted" are a single thickness-px stroke traced
|
||||||
just inside the image's edge; "fancy" is two thinner concentric
|
just inside the image's edge; "fancy" is two thinner concentric
|
||||||
strokes with a gap between them, picture-frame-mat style. "none" (or
|
strokes with a gap between them, picture-frame-mat style. "none" (or
|
||||||
a non-positive thickness) draws nothing."""
|
a non-positive thickness) draws nothing. `radius` is opt-in and only
|
||||||
|
honored by "solid"/"fancy" (rounded_rectangle instead of rectangle) --
|
||||||
|
"dashed"/"dotted" trace each of the 4 edges as independent straight
|
||||||
|
segments (see _dashed_edge/_dotted_edge) and ignore it, a documented
|
||||||
|
limitation rather than a bug. Defaults to 0 (unchanged sharp-corner
|
||||||
|
behavior) and no call site passes non-zero today -- this ships the
|
||||||
|
capability for a future per-widget "rounded border" setting without
|
||||||
|
changing default behavior anywhere (see tests/test_widget_border.py's
|
||||||
|
exact-corner-pixel assertions)."""
|
||||||
if style == "none" or thickness <= 0:
|
if style == "none" or thickness <= 0:
|
||||||
return
|
return
|
||||||
w, h = img.size
|
w, h = img.size
|
||||||
t = max(1, min(int(thickness), min(w, h) // 2))
|
t = max(1, min(int(thickness), min(w, h) // 2))
|
||||||
draw = ImageDraw.Draw(img)
|
draw = ImageDraw.Draw(img)
|
||||||
|
r = max(0, min(radius, (w - 1) // 2, (h - 1) // 2))
|
||||||
|
|
||||||
if style == "fancy":
|
if style == "fancy":
|
||||||
line_t = max(1, t // 3)
|
line_t = max(1, t // 3)
|
||||||
gap = max(2, t - 2 * line_t)
|
gap = max(2, t - 2 * line_t)
|
||||||
draw.rectangle([0, 0, w - 1, h - 1], outline=color, width=line_t)
|
if r:
|
||||||
|
draw.rounded_rectangle([0, 0, w - 1, h - 1], radius=r, outline=color, width=line_t)
|
||||||
|
else:
|
||||||
|
draw.rectangle([0, 0, w - 1, h - 1], outline=color, width=line_t)
|
||||||
inset = line_t + gap
|
inset = line_t + gap
|
||||||
if w - 2 * inset > 1 and h - 2 * inset > 1:
|
if w - 2 * inset > 1 and h - 2 * inset > 1:
|
||||||
draw.rectangle([inset, inset, w - 1 - inset, h - 1 - inset], outline=color, width=line_t)
|
inner_r = max(0, min(r - inset, (w - 1 - 2 * inset) // 2, (h - 1 - 2 * inset) // 2)) if r else 0
|
||||||
|
if inner_r:
|
||||||
|
draw.rounded_rectangle([inset, inset, w - 1 - inset, h - 1 - inset], radius=inner_r,
|
||||||
|
outline=color, width=line_t)
|
||||||
|
else:
|
||||||
|
draw.rectangle([inset, inset, w - 1 - inset, h - 1 - inset], outline=color, width=line_t)
|
||||||
return
|
return
|
||||||
|
|
||||||
if style == "solid":
|
if style == "solid":
|
||||||
draw.rectangle([0, 0, w - 1, h - 1], outline=color, width=t)
|
if r:
|
||||||
|
draw.rounded_rectangle([0, 0, w - 1, h - 1], radius=r, outline=color, width=t)
|
||||||
|
else:
|
||||||
|
draw.rectangle([0, 0, w - 1, h - 1], outline=color, width=t)
|
||||||
return
|
return
|
||||||
|
|
||||||
# dashed/dotted trace the same centered-on-the-edge path solid/
|
# dashed/dotted trace the same centered-on-the-edge path solid/
|
||||||
@@ -149,28 +169,45 @@ def logical_to_native(x: float, y: float, orientation: str) -> tuple[int, int]:
|
|||||||
return int(logical_h - 1 - y), int(x)
|
return int(logical_h - 1 - y), int(x)
|
||||||
return int(x), int(y)
|
return int(x), int(y)
|
||||||
|
|
||||||
# Measured sRGB appearance of each of the panel's 6 ink colors on an
|
# Approximate sRGB for each of the panel's 6 ink colors -- reasonable
|
||||||
# actual Spectra 6 panel -- sourced from epdoptimize's "spectra6" palette
|
# placeholders, not measured values (Waveshare doesn't publish exact
|
||||||
# (github.com/paperlesspaper/epdoptimize, src/dither/data/default-palettes
|
# color primaries for this panel). This is the fallback for any frame
|
||||||
# .json), not our own calibration, but a much better starting point than a
|
# that hasn't tuned its own (Frame.palette_rgb, set from a frame's
|
||||||
# guess: e-ink ink never reaches full sRGB saturation/contrast, so this is
|
# Configuration tab -- "Advanced configuration" -- once you can compare
|
||||||
# uniformly darker and more muted than the naive (0,0,0)/(255,255,255)/pure
|
# a rendered test image against the real panel; different panel units
|
||||||
# hues this used to be. This is the fallback for any frame that hasn't
|
# can vary enough to be worth calibrating per frame).
|
||||||
# tuned its own (Frame.palette_rgb, set from a frame's Configuration tab
|
|
||||||
# -- "Advanced configuration" -- once you can compare a rendered test
|
|
||||||
# image against the real panel; different panel units can vary enough to
|
|
||||||
# be worth calibrating per frame).
|
|
||||||
DEFAULT_PALETTE_RGB = [
|
DEFAULT_PALETTE_RGB = [
|
||||||
(31, 34, 38), # BLACK
|
(0, 0, 0), # BLACK
|
||||||
(185, 199, 201), # WHITE
|
(255, 255, 255), # WHITE
|
||||||
(193, 187, 30), # YELLOW
|
(255, 219, 0), # YELLOW
|
||||||
(98, 32, 30), # RED
|
(207, 0, 15), # RED
|
||||||
(35, 63, 142), # BLUE
|
(0, 39, 133), # BLUE
|
||||||
(53, 86, 58), # GREEN
|
(0, 133, 55), # GREEN
|
||||||
]
|
]
|
||||||
|
|
||||||
PALETTE_LABELS = ["Black", "White", "Yellow", "Red", "Blue", "Green"]
|
PALETTE_LABELS = ["Black", "White", "Yellow", "Red", "Blue", "Green"]
|
||||||
|
|
||||||
|
# A community-measured alternative starting point for the same 6 slots,
|
||||||
|
# ported (data only, not code) from paperlesspaper/epdoptimize's
|
||||||
|
# src/dither/data/default-palettes.json "spectra6" entry (Apache
|
||||||
|
# License 2.0, https://github.com/paperlesspaper/epdoptimize) -- offered
|
||||||
|
# as a one-click "Load calibrated preset" in the Advanced configuration
|
||||||
|
# UI, not a new default: unlike DEFAULT_PALETTE_RGB above, these are an
|
||||||
|
# actual panel's measured appearance rather than idealized primaries
|
||||||
|
# (real Spectra 6 white/black are notably duller than pure #fff/#000),
|
||||||
|
# but measured from a different unit than any given frame's actual
|
||||||
|
# panel -- panel_style.py's own docstring already notes units vary
|
||||||
|
# enough to be worth calibrating per frame, and this hasn't been
|
||||||
|
# verified against this project's own hardware.
|
||||||
|
CALIBRATED_SPECTRA6_RGB = [
|
||||||
|
(0x1F, 0x22, 0x26), # BLACK
|
||||||
|
(0xB9, 0xC7, 0xC9), # WHITE
|
||||||
|
(0xC1, 0xBB, 0x1E), # YELLOW
|
||||||
|
(0x62, 0x20, 0x1E), # RED
|
||||||
|
(0x23, 0x3F, 0x8E), # BLUE
|
||||||
|
(0x35, 0x56, 0x3A), # GREEN
|
||||||
|
]
|
||||||
|
|
||||||
# The panel's actual 4-bit color codes (see firmware/components/epd7in3e),
|
# The panel's actual 4-bit color codes (see firmware/components/epd7in3e),
|
||||||
# in the same order as DEFAULT_PALETTE_RGB/PALETTE_LABELS -- fixed by the
|
# in the same order as DEFAULT_PALETTE_RGB/PALETTE_LABELS -- fixed by the
|
||||||
# hardware protocol, never user-configurable. 0x4 is intentionally unused
|
# hardware protocol, never user-configurable. 0x4 is intentionally unused
|
||||||
@@ -227,137 +264,10 @@ def hex_to_rgb(hex_str: str) -> tuple[int, int, int] | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _rgb_to_oklab(rgb: "np.ndarray") -> "np.ndarray":
|
def _build_palette_image(palette_rgb: list) -> Image.Image:
|
||||||
"""(...,3) uint8/float sRGB -> (...,3) float32 OKLab (Bjorn Ottosson's
|
pal_img = Image.new("P", (1, 1))
|
||||||
formulation, https://bottosson.github.io/posts/oklab/). Used instead
|
pal_img.putpalette([channel for rgb in palette_rgb for channel in rgb])
|
||||||
of raw RGB distance for palette matching/error diffusion below --
|
return pal_img
|
||||||
Euclidean distance in OKLab tracks perceived color difference far
|
|
||||||
better than in RGB, which matters a lot once the "colors" being
|
|
||||||
matched against are a 6-entry palette this coarse."""
|
|
||||||
linear = (rgb.astype(np.float32) / 255.0)
|
|
||||||
linear = np.where(linear <= 0.04045, linear / 12.92, ((linear + 0.055) / 1.055) ** 2.4)
|
|
||||||
r, g, b = linear[..., 0], linear[..., 1], linear[..., 2]
|
|
||||||
|
|
||||||
l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b
|
|
||||||
m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b
|
|
||||||
s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b
|
|
||||||
l_, m_, s_ = np.cbrt(l), np.cbrt(m), np.cbrt(s)
|
|
||||||
|
|
||||||
L = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_
|
|
||||||
a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_
|
|
||||||
b2 = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_
|
|
||||||
return np.stack([L, a, b2], axis=-1)
|
|
||||||
|
|
||||||
|
|
||||||
# Lightness is weighted down relative to a/b when *choosing* the nearest
|
|
||||||
# palette entry (established color-difference formulas -- CIE94, CMC --
|
|
||||||
# do the same, on the general principle that a lightness mismatch reads
|
|
||||||
# as less objectionable than a hue mismatch). Not optional polish: this
|
|
||||||
# palette's ink colors are far darker/lighter than their sRGB namesakes
|
|
||||||
# (e.g. "red" ink is a dark #62201E, "yellow" ink is a bright #C1BB1E),
|
|
||||||
# so unweighted OKLab distance lets that lightness gap dominate and pure
|
|
||||||
# saturated red (high L) ends up nearer "yellow" (L=0.77) than "red"
|
|
||||||
# (L=0.35) even though red is unambiguously closer in hue/chroma (a/b) --
|
|
||||||
# confirmed both analytically and by DEFAULT_PALETTE_RGB's own test
|
|
||||||
# coverage (test_render_size_invariants.py's pure-red/pure-blue check).
|
|
||||||
_LIGHTNESS_MATCH_WEIGHT = 0.5
|
|
||||||
|
|
||||||
|
|
||||||
def _nearest_palette_indices(oklab_pixels: "np.ndarray", palette_oklab: "np.ndarray") -> "np.ndarray":
|
|
||||||
"""(H,W,3) OKLab pixels, (K,3) OKLab palette -> (H,W) index array, no
|
|
||||||
error diffusion -- the "flat"/undithered quantization, vectorized
|
|
||||||
(K is always 6, so brute-force all-pairs distance is cheap and this
|
|
||||||
stays a single numpy call rather than a per-pixel Python loop)."""
|
|
||||||
diffs2 = (oklab_pixels[:, :, None, :] - palette_oklab[None, None, :, :]) ** 2
|
|
||||||
weights = np.array([_LIGHTNESS_MATCH_WEIGHT, 1.0, 1.0], dtype=np.float32)
|
|
||||||
dist2 = (diffs2 * weights).sum(axis=-1)
|
|
||||||
return np.argmin(dist2, axis=2)
|
|
||||||
|
|
||||||
|
|
||||||
def _bayer_matrix(n: int) -> "np.ndarray":
|
|
||||||
"""Recursive construction of the standard n x n (n a power of 2)
|
|
||||||
Bayer ordered-dithering threshold matrix, values 0..n*n-1, each used
|
|
||||||
exactly once -- the classic recursive doubling
|
|
||||||
(https://en.wikipedia.org/wiki/Ordered_dithering)."""
|
|
||||||
if n == 1:
|
|
||||||
return np.zeros((1, 1))
|
|
||||||
smaller = _bayer_matrix(n // 2)
|
|
||||||
return np.block([
|
|
||||||
[4 * smaller, 4 * smaller + 2],
|
|
||||||
[4 * smaller + 3, 4 * smaller + 1],
|
|
||||||
])
|
|
||||||
|
|
||||||
|
|
||||||
# Normalized to [0, 1): a deterministic per-pixel threshold tiled across
|
|
||||||
# the image, used (like classic ordered/Bayer dithering) to decide, for
|
|
||||||
# each pixel, whether it plots as its nearest or second-nearest palette
|
|
||||||
# color -- see _ordered_dither_oklab.
|
|
||||||
_BAYER_8 = (_bayer_matrix(8) + 0.5) / 64.0
|
|
||||||
|
|
||||||
|
|
||||||
def _ordered_dither_oklab(oklab_pixels: "np.ndarray", palette_oklab: "np.ndarray") -> "np.ndarray":
|
|
||||||
"""(H,W,3) OKLab pixels, (K,3) OKLab palette -> (H,W) uint8 index
|
|
||||||
array, ordered (Bayer matrix) dithering -- picked over error
|
|
||||||
diffusion (Floyd-Steinberg/Atkinson/etc.) specifically because it's
|
|
||||||
fully vectorizable: no pixel-to-pixel dependency to chain through a
|
|
||||||
Python loop, just a fixed number of numpy calls over the whole
|
|
||||||
image. A straight per-pixel error-diffusion loop in Python was
|
|
||||||
measured at ~1s for a full 800x480 panel -- see
|
|
||||||
test_widgets_render_concurrently's latency budget (the whole reason
|
|
||||||
widgets render concurrently in the first place, see git history) --
|
|
||||||
which this avoids entirely.
|
|
||||||
|
|
||||||
Finds each pixel's true nearest and second-nearest palette color and
|
|
||||||
mixes between exactly those two, using the Bayer threshold as the
|
|
||||||
per-pixel coin flip -- the standard generalization of ordered
|
|
||||||
dithering to a palette whose entries aren't evenly spaced (unlike,
|
|
||||||
say, dithering 0-255 gray down to a handful of even steps). The
|
|
||||||
mixing fraction is the pixel's projection onto the segment from its
|
|
||||||
nearest color to its second-nearest, NOT distance-to-nearest over
|
|
||||||
total distance (d0/(d0+d1)) -- an earlier version used that ratio
|
|
||||||
and it's wrong whenever the second-nearest color is simply far away
|
|
||||||
in an unrelated direction rather than genuinely "on the other side"
|
|
||||||
of the pixel: d1 being large made the ratio look small-mixing-needed
|
|
||||||
only when d0 was *also* comparably large, so a pixel sitting almost
|
|
||||||
exactly on its nearest color still got a large fraction of an
|
|
||||||
unrelated second color -- confirmed visually as entire regions
|
|
||||||
(e.g. a pale sky, clearly nearest White) rendering as flat blocks of
|
|
||||||
a wrong, unrelated color (Yellow) instead of White. Projection onto
|
|
||||||
the actual nearest-neighbor segment doesn't have that failure mode:
|
|
||||||
a pixel essentially at c0 projects to ~0 regardless of where c1 is."""
|
|
||||||
weights = np.array([_LIGHTNESS_MATCH_WEIGHT, 1.0, 1.0], dtype=np.float32)
|
|
||||||
scale = np.sqrt(weights)
|
|
||||||
pixels_w = oklab_pixels * scale
|
|
||||||
palette_w = palette_oklab * scale
|
|
||||||
|
|
||||||
dist2 = ((pixels_w[:, :, None, :] - palette_w[None, None, :, :]) ** 2).sum(axis=-1) # (H, W, K)
|
|
||||||
order = np.argsort(dist2, axis=-1)
|
|
||||||
idx0, idx1 = order[..., 0], order[..., 1]
|
|
||||||
|
|
||||||
c0 = palette_w[idx0] # (H, W, 3)
|
|
||||||
c1 = palette_w[idx1] # (H, W, 3)
|
|
||||||
segment = c1 - c0
|
|
||||||
to_pixel = pixels_w - c0
|
|
||||||
segment_len2 = (segment * segment).sum(axis=-1)
|
|
||||||
t = np.divide((to_pixel * segment).sum(axis=-1), segment_len2,
|
|
||||||
out=np.zeros_like(segment_len2), where=segment_len2 > 1e-12)
|
|
||||||
t = np.clip(t, 0.0, 1.0)
|
|
||||||
|
|
||||||
h, w, _ = oklab_pixels.shape
|
|
||||||
threshold = np.tile(_BAYER_8, (h // 8 + 1, w // 8 + 1))[:h, :w]
|
|
||||||
use_second = threshold < t
|
|
||||||
return np.where(use_second, idx1, idx0).astype(np.uint8)
|
|
||||||
|
|
||||||
|
|
||||||
def _index_array_to_p_image(idx_array: "np.ndarray", palette_rgb: list) -> Image.Image:
|
|
||||||
"""(H,W) palette-index array -> a PIL "P"-mode image carrying
|
|
||||||
`palette_rgb` as its palette, so downstream code (as_png's
|
|
||||||
.convert("RGB"), _transpose_and_pack's pixels[x, y] index lookups)
|
|
||||||
behaves exactly as it did with PIL's own quantize()."""
|
|
||||||
img = Image.fromarray(idx_array, mode="P")
|
|
||||||
padded = list(palette_rgb) + [(0, 0, 0)] * (256 - len(palette_rgb))
|
|
||||||
img.putpalette([channel for rgb in padded for channel in rgb])
|
|
||||||
return img
|
|
||||||
|
|
||||||
|
|
||||||
def _plain_center_crop_box(
|
def _plain_center_crop_box(
|
||||||
@@ -535,39 +445,21 @@ def _enhance(img: Image.Image, color_boost: float, contrast_boost: float) -> Ima
|
|||||||
|
|
||||||
def _quantize(img: Image.Image, palette_rgb: list | None, dither_strength: float) -> Image.Image:
|
def _quantize(img: Image.Image, palette_rgb: list | None, dither_strength: float) -> Image.Image:
|
||||||
"""RGB -> palette-quantized P-mode image, same size/orientation as
|
"""RGB -> palette-quantized P-mode image, same size/orientation as
|
||||||
`img` (no rotation here). Matches against the palette in OKLab space
|
`img` (no rotation here). dither_strength blends `img` toward its own
|
||||||
(perceptual distance, not raw RGB -- see _rgb_to_oklab/
|
flat (undithered) quantization before running Floyd-Steinberg on the
|
||||||
_nearest_palette_indices) and, when dithering, jitters that match
|
blend: at 0 there's zero quantization error left to diffuse (so the
|
||||||
with a Bayer ordered-dither pattern rather than Floyd-Steinberg error
|
result IS the flat quantization, no dithering texture at all); at 1
|
||||||
diffusion -- see _ordered_dither_oklab for why (short version: error
|
it's `img` unchanged (full-strength dithering, this project's
|
||||||
diffusion is inherently a serial per-pixel loop, and doing that in
|
original always-on behavior); values between give a smooth continuum
|
||||||
Python for a full 800x480 panel blew well past this project's
|
of dithering intensity rather than an on/off toggle."""
|
||||||
render-latency budget). dither_strength blends `img` toward its own
|
palette_image = _build_palette_image(palette_rgb or DEFAULT_PALETTE_RGB)
|
||||||
flat (undithered) quantization before dithering the blend: at 0 the
|
if dither_strength >= 1.0:
|
||||||
blend IS the flat quantization (nothing left for the jitter to push
|
return img.quantize(palette=palette_image, dither=Image.Dither.FLOYDSTEINBERG)
|
||||||
across a color boundary, so no dithering texture at all); at 1 it's
|
|
||||||
`img` unchanged (full-strength dithering, this project's original
|
|
||||||
always-on behavior); values between give a smooth continuum of
|
|
||||||
dithering intensity rather than an on/off toggle."""
|
|
||||||
palette_rgb = palette_rgb or DEFAULT_PALETTE_RGB
|
|
||||||
palette_oklab = _rgb_to_oklab(np.asarray(palette_rgb, dtype=np.float32))
|
|
||||||
|
|
||||||
rgb_array = np.asarray(img.convert("RGB"))
|
|
||||||
oklab_pixels = _rgb_to_oklab(rgb_array)
|
|
||||||
|
|
||||||
if dither_strength <= 0.0:
|
if dither_strength <= 0.0:
|
||||||
flat_idx = _nearest_palette_indices(oklab_pixels, palette_oklab)
|
return img.quantize(palette=palette_image, dither=Image.Dither.NONE)
|
||||||
return _index_array_to_p_image(flat_idx.astype(np.uint8), palette_rgb)
|
flat = img.quantize(palette=palette_image, dither=Image.Dither.NONE).convert("RGB")
|
||||||
|
blended = Image.blend(flat, img, dither_strength)
|
||||||
if dither_strength < 1.0:
|
return blended.quantize(palette=palette_image, dither=Image.Dither.FLOYDSTEINBERG)
|
||||||
flat_idx = _nearest_palette_indices(oklab_pixels, palette_oklab)
|
|
||||||
palette_arr = np.asarray(palette_rgb, dtype=np.uint8)
|
|
||||||
flat_rgb = Image.fromarray(palette_arr[flat_idx], mode="RGB")
|
|
||||||
blended = Image.blend(flat_rgb, img.convert("RGB"), dither_strength)
|
|
||||||
oklab_pixels = _rgb_to_oklab(np.asarray(blended))
|
|
||||||
|
|
||||||
dithered_idx = _ordered_dither_oklab(oklab_pixels, palette_oklab)
|
|
||||||
return _index_array_to_p_image(dithered_idx, palette_rgb)
|
|
||||||
|
|
||||||
|
|
||||||
def _transpose_and_pack(quantized: Image.Image, orientation: str) -> bytes:
|
def _transpose_and_pack(quantized: Image.Image, orientation: str) -> bytes:
|
||||||
|
|||||||
+16
-2
@@ -17,6 +17,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
||||||
@@ -24,7 +25,7 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from . import logging_setup, migration
|
from . import html_render, logging_setup, migration
|
||||||
from .auth import (
|
from .auth import (
|
||||||
browser_token_valid,
|
browser_token_valid,
|
||||||
current_user,
|
current_user,
|
||||||
@@ -46,7 +47,20 @@ logging_setup.configure_logging()
|
|||||||
# Schema + legacy-config import, before the first request is served.
|
# Schema + legacy-config import, before the first request is served.
|
||||||
migration.run_migrations()
|
migration.run_migrations()
|
||||||
|
|
||||||
app = FastAPI(title="ESPresso Frame Server")
|
@asynccontextmanager
|
||||||
|
async def _lifespan(app: FastAPI):
|
||||||
|
"""Startup does nothing browser-related -- html_render.start() is
|
||||||
|
lazy (only the weather widget's opt-in "modern" render style ever
|
||||||
|
triggers it, see that module's docstring), so a deployment that
|
||||||
|
never uses it never launches Chromium or needs Playwright's browser
|
||||||
|
binaries installed. Shutdown calls html_render.stop() unconditionally
|
||||||
|
(a no-op if it was never started) so a server restart never leaves
|
||||||
|
an orphaned Chromium process running."""
|
||||||
|
yield
|
||||||
|
html_render.stop()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="ESPresso Frame Server", lifespan=_lifespan)
|
||||||
templates = Jinja2Templates(directory="app/templates")
|
templates = Jinja2Templates(directory="app/templates")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
from .image_pipeline import DEFAULT_PALETTE_RGB, draw_text
|
from . import panel_style
|
||||||
|
from .image_pipeline import draw_text
|
||||||
|
|
||||||
PADDING = 16
|
PADDING = 16
|
||||||
QR_TEXT_GAP = 8
|
QR_TEXT_GAP = 8
|
||||||
@@ -27,9 +28,12 @@ BODY_FONT_SIZE = 20
|
|||||||
|
|
||||||
BATTERY_ICON_W = 40
|
BATTERY_ICON_W = 40
|
||||||
BATTERY_ICON_H = 22
|
BATTERY_ICON_H = 22
|
||||||
BATTERY_ICON_STROKE = 2
|
# Stroke/nub width/height are no longer fixed constants here -- panel_
|
||||||
|
# style.draw_battery_icon derives them from icon_w/icon_h itself (same
|
||||||
|
# formula widgets/battery.py's own icon already used). BATTERY_NUB_W
|
||||||
|
# below is kept only as this box's own outer-width estimate, not fed
|
||||||
|
# into the icon drawing itself.
|
||||||
BATTERY_NUB_W = 5
|
BATTERY_NUB_W = 5
|
||||||
BATTERY_NUB_H = 10
|
|
||||||
BATTERY_ICON_TEXT_GAP = 8
|
BATTERY_ICON_TEXT_GAP = 8
|
||||||
BATTERY_REGION_GAP = 8 # vertical gap below the manage QR box
|
BATTERY_REGION_GAP = 8 # vertical gap below the manage QR box
|
||||||
|
|
||||||
@@ -37,10 +41,6 @@ FACE_LABEL_PADDING = 8
|
|||||||
FACE_LABEL_GAP = 4 # distance from the face's anchor point to the label box
|
FACE_LABEL_GAP = 4 # distance from the face's anchor point to the label box
|
||||||
|
|
||||||
|
|
||||||
def _font(size: int) -> ImageFont.ImageFont:
|
|
||||||
return ImageFont.load_default(size=size)
|
|
||||||
|
|
||||||
|
|
||||||
def _qr_image(url: str, target_px: int = QR_TARGET_PX) -> Image.Image:
|
def _qr_image(url: str, target_px: int = QR_TARGET_PX) -> Image.Image:
|
||||||
import qrcode
|
import qrcode
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ def _qr_image(url: str, target_px: int = QR_TARGET_PX) -> Image.Image:
|
|||||||
return raw.resize((raw.width * scale, raw.height * scale), Image.NEAREST)
|
return raw.resize((raw.width * scale, raw.height * scale), Image.NEAREST)
|
||||||
|
|
||||||
|
|
||||||
def _text_box(draw: ImageDraw.ImageDraw, lines: list[str], font: ImageFont.ImageFont) -> tuple[int, int]:
|
def _text_box(draw: ImageDraw.ImageDraw, lines: list[str], font: ImageFont.FreeTypeFont) -> tuple[int, int]:
|
||||||
"""(width, height) of `lines` stacked with LINE_GAP between them, at
|
"""(width, height) of `lines` stacked with LINE_GAP between them, at
|
||||||
`font` -- the box _draw_text_box below will need."""
|
`font` -- the box _draw_text_box below will need."""
|
||||||
w = 0
|
w = 0
|
||||||
@@ -64,7 +64,7 @@ def _text_box(draw: ImageDraw.ImageDraw, lines: list[str], font: ImageFont.Image
|
|||||||
return w, h
|
return w, h
|
||||||
|
|
||||||
|
|
||||||
def _draw_centered_lines(img: Image.Image, draw: ImageDraw.ImageDraw, lines: list[str], font: ImageFont.ImageFont,
|
def _draw_centered_lines(img: Image.Image, draw: ImageDraw.ImageDraw, lines: list[str], font: ImageFont.FreeTypeFont,
|
||||||
center_x: int, top: int) -> None:
|
center_x: int, top: int) -> None:
|
||||||
y = top
|
y = top
|
||||||
for line in lines:
|
for line in lines:
|
||||||
@@ -82,7 +82,8 @@ def _draw_qr_box(img: Image.Image, draw: ImageDraw.ImageDraw, url: str, caption:
|
|||||||
(the battery, below the manage QR) use it instead of recomputing the
|
(the battery, below the manage QR) use it instead of recomputing the
|
||||||
same geometry a second time."""
|
same geometry a second time."""
|
||||||
qr_img = _qr_image(url)
|
qr_img = _qr_image(url)
|
||||||
text_w, text_h = _text_box(draw, caption, _font(TITLE_FONT_SIZE)) if caption else (0, 0)
|
caption_font = panel_style.font_bold(TITLE_FONT_SIZE)
|
||||||
|
text_w, text_h = _text_box(draw, caption, caption_font) if caption else (0, 0)
|
||||||
content_w = max(qr_img.width, text_w)
|
content_w = max(qr_img.width, text_w)
|
||||||
content_h = qr_img.height + (QR_TEXT_GAP + text_h if caption else 0)
|
content_h = qr_img.height + (QR_TEXT_GAP + text_h if caption else 0)
|
||||||
|
|
||||||
@@ -90,24 +91,26 @@ def _draw_qr_box(img: Image.Image, draw: ImageDraw.ImageDraw, url: str, caption:
|
|||||||
h = content_h + PADDING * 2
|
h = content_h + PADDING * 2
|
||||||
x0, y0 = _corner_origin(img.size, (w, h), corner)
|
x0, y0 = _corner_origin(img.size, (w, h), corner)
|
||||||
|
|
||||||
draw.rectangle([x0, y0, x0 + w, y0 + h], fill=(255, 255, 255), outline=(0, 0, 0))
|
draw.rounded_rectangle([x0, y0, x0 + w, y0 + h], radius=panel_style.CHIP_RADIUS,
|
||||||
|
fill=(255, 255, 255), outline=(0, 0, 0))
|
||||||
center_x = x0 + w // 2
|
center_x = x0 + w // 2
|
||||||
img.paste(qr_img, (center_x - qr_img.width // 2, y0 + PADDING))
|
img.paste(qr_img, (center_x - qr_img.width // 2, y0 + PADDING))
|
||||||
if caption:
|
if caption:
|
||||||
_draw_centered_lines(img, draw, caption, _font(TITLE_FONT_SIZE), center_x, y0 + PADDING + qr_img.height + QR_TEXT_GAP)
|
_draw_centered_lines(img, draw, caption, caption_font, center_x, y0 + PADDING + qr_img.height + QR_TEXT_GAP)
|
||||||
return x0, y0, w, h
|
return x0, y0, w, h
|
||||||
|
|
||||||
|
|
||||||
def _draw_text_box(img: Image.Image, draw: ImageDraw.ImageDraw, lines: list[str], corner: str) -> None:
|
def _draw_text_box(img: Image.Image, draw: ImageDraw.ImageDraw, lines: list[str], corner: str) -> None:
|
||||||
"""White-padded box with centered text lines, placed in one of the
|
"""White-padded box with centered text lines, placed in one of the
|
||||||
panel's four corners."""
|
panel's four corners."""
|
||||||
font = _font(BODY_FONT_SIZE)
|
font = panel_style.font_regular(BODY_FONT_SIZE)
|
||||||
text_w, text_h = _text_box(draw, lines, font)
|
text_w, text_h = _text_box(draw, lines, font)
|
||||||
w = text_w + PADDING * 2
|
w = text_w + PADDING * 2
|
||||||
h = text_h + PADDING * 2
|
h = text_h + PADDING * 2
|
||||||
x0, y0 = _corner_origin(img.size, (w, h), corner)
|
x0, y0 = _corner_origin(img.size, (w, h), corner)
|
||||||
|
|
||||||
draw.rectangle([x0, y0, x0 + w, y0 + h], fill=(255, 255, 255), outline=(0, 0, 0))
|
draw.rounded_rectangle([x0, y0, x0 + w, y0 + h], radius=panel_style.CHIP_RADIUS,
|
||||||
|
fill=(255, 255, 255), outline=(0, 0, 0))
|
||||||
_draw_centered_lines(img, draw, lines, font, x0 + w // 2, y0 + PADDING)
|
_draw_centered_lines(img, draw, lines, font, x0 + w // 2, y0 + PADDING)
|
||||||
|
|
||||||
|
|
||||||
@@ -123,34 +126,17 @@ def _corner_origin(img_size: tuple[int, int], box_size: tuple[int, int], corner:
|
|||||||
return img_w - PANEL_MARGIN - box_w, img_h - PANEL_MARGIN - box_h # bottom-right
|
return img_w - PANEL_MARGIN - box_w, img_h - PANEL_MARGIN - box_h # bottom-right
|
||||||
|
|
||||||
|
|
||||||
# DEFAULT_PALETTE_RGB order is [BLACK, WHITE, YELLOW, RED, BLUE, GREEN]
|
|
||||||
# (see image_pipeline.PANEL_CODES) -- picked by level so the fill itself
|
|
||||||
# carries the "how worried should I be" signal, not just the number next
|
|
||||||
# to it. Thresholds match the low-battery-alert spirit elsewhere in this
|
|
||||||
# project (not tied to a frame's own configured alert threshold, since
|
|
||||||
# this glyph has to make sense with no configuration at all).
|
|
||||||
_BATTERY_LOW = DEFAULT_PALETTE_RGB[3] # red
|
|
||||||
_BATTERY_MEDIUM = DEFAULT_PALETTE_RGB[2] # yellow
|
|
||||||
_BATTERY_HIGH = DEFAULT_PALETTE_RGB[5] # green
|
|
||||||
|
|
||||||
|
|
||||||
def _battery_fill_color(percent: int) -> tuple[int, int, int]:
|
|
||||||
if percent <= 15:
|
|
||||||
return _BATTERY_LOW
|
|
||||||
if percent <= 40:
|
|
||||||
return _BATTERY_MEDIUM
|
|
||||||
return _BATTERY_HIGH
|
|
||||||
|
|
||||||
|
|
||||||
def _draw_battery(img: Image.Image, draw: ImageDraw.ImageDraw, percent: int, anchor_x0: int, anchor_y0: int,
|
def _draw_battery(img: Image.Image, draw: ImageDraw.ImageDraw, percent: int, anchor_x0: int, anchor_y0: int,
|
||||||
anchor_w: int, anchor_h: int) -> None:
|
anchor_w: int, anchor_h: int) -> None:
|
||||||
"""Battery glyph (now actually filled to `percent`, not just a static
|
"""Battery glyph + "NN%" text, right-aligned under the given anchor
|
||||||
outline -- easy now that this renders server-side instead of being a
|
box (the manage QR box) -- a sensible default position, not a
|
||||||
fixed bitmap firmware drew) + "NN%" text, right-aligned under the
|
constraint anything else has to route around; move this call site's
|
||||||
given anchor box (the manage QR box) -- a sensible default position,
|
arguments to place it anywhere else instead. The glyph itself is
|
||||||
not a constraint anything else has to route around; move this call
|
panel_style.draw_battery_icon -- the one shared implementation
|
||||||
site's arguments to place it anywhere else instead."""
|
replacing what used to be a second, independent copy of widgets/
|
||||||
font = _font(BODY_FONT_SIZE)
|
battery.py's own icon-drawing code (same shape, same red/yellow/
|
||||||
|
green thresholds, previously kept in sync by convention only)."""
|
||||||
|
font = panel_style.font_regular(BODY_FONT_SIZE)
|
||||||
text = f"{percent}%"
|
text = f"{percent}%"
|
||||||
icon_total_w = BATTERY_ICON_W + BATTERY_NUB_W
|
icon_total_w = BATTERY_ICON_W + BATTERY_NUB_W
|
||||||
text_w = draw.textlength(text, font=font)
|
text_w = draw.textlength(text, font=font)
|
||||||
@@ -162,22 +148,14 @@ def _draw_battery(img: Image.Image, draw: ImageDraw.ImageDraw, percent: int, anc
|
|||||||
x0 = anchor_x0 + anchor_w - w
|
x0 = anchor_x0 + anchor_w - w
|
||||||
y0 = anchor_y0 + anchor_h + BATTERY_REGION_GAP
|
y0 = anchor_y0 + anchor_h + BATTERY_REGION_GAP
|
||||||
|
|
||||||
draw.rectangle([x0, y0, x0 + w, y0 + h], fill=(255, 255, 255), outline=(0, 0, 0))
|
draw.rounded_rectangle([x0, y0, x0 + w, y0 + h], radius=panel_style.CHIP_RADIUS,
|
||||||
|
fill=(255, 255, 255), outline=(0, 0, 0))
|
||||||
|
|
||||||
icon_x = x0 + PADDING
|
icon_x = x0 + PADDING
|
||||||
icon_y = y0 + PADDING + (content_h - BATTERY_ICON_H) // 2
|
icon_y = y0 + PADDING + (content_h - BATTERY_ICON_H) // 2
|
||||||
inner_x0, inner_y0 = icon_x + BATTERY_ICON_STROKE, icon_y + BATTERY_ICON_STROKE
|
panel_style.draw_battery_icon(draw, icon_x, icon_y, BATTERY_ICON_W, BATTERY_ICON_H, percent)
|
||||||
inner_x1, inner_y1 = icon_x + BATTERY_ICON_W - BATTERY_ICON_STROKE, icon_y + BATTERY_ICON_H - BATTERY_ICON_STROKE
|
|
||||||
fill_x1 = inner_x0 + round((inner_x1 - inner_x0) * (percent / 100))
|
|
||||||
if fill_x1 > inner_x0:
|
|
||||||
draw.rectangle([inner_x0, inner_y0, fill_x1, inner_y1], fill=_battery_fill_color(percent))
|
|
||||||
draw.rectangle([icon_x, icon_y, icon_x + BATTERY_ICON_W, icon_y + BATTERY_ICON_H], outline=(0, 0, 0),
|
|
||||||
width=BATTERY_ICON_STROKE)
|
|
||||||
nub_y = icon_y + (BATTERY_ICON_H - BATTERY_NUB_H) // 2
|
|
||||||
draw.rectangle([icon_x + BATTERY_ICON_W, nub_y, icon_x + BATTERY_ICON_W + BATTERY_NUB_W, nub_y + BATTERY_NUB_H],
|
|
||||||
fill=(0, 0, 0))
|
|
||||||
draw_text(img, (icon_x + icon_total_w + BATTERY_ICON_TEXT_GAP, y0 + PADDING + (content_h - font.size) // 2),
|
draw_text(img, (icon_x + icon_total_w + BATTERY_ICON_TEXT_GAP, y0 + PADDING + (content_h - font.size) // 2),
|
||||||
text, font)
|
text, font, panel_style.battery_fill_color(percent))
|
||||||
|
|
||||||
|
|
||||||
def _draw_face_label(img: Image.Image, draw: ImageDraw.ImageDraw, name: str, anchor_x: int, anchor_y: int) -> None:
|
def _draw_face_label(img: Image.Image, draw: ImageDraw.ImageDraw, name: str, anchor_x: int, anchor_y: int) -> None:
|
||||||
@@ -185,7 +163,7 @@ def _draw_face_label(img: Image.Image, draw: ImageDraw.ImageDraw, name: str, anc
|
|||||||
anchor_y) point, flipped above if there's no room below, clamped to
|
anchor_y) point, flipped above if there's no room below, clamped to
|
||||||
stay fully on-panel -- unlike the four corner boxes (always in-bounds
|
stay fully on-panel -- unlike the four corner boxes (always in-bounds
|
||||||
by construction), a face can be anywhere, including near an edge."""
|
by construction), a face can be anywhere, including near an edge."""
|
||||||
font = _font(BODY_FONT_SIZE)
|
font = panel_style.font_regular(BODY_FONT_SIZE)
|
||||||
text_w = draw.textlength(name, font=font)
|
text_w = draw.textlength(name, font=font)
|
||||||
bbox = draw.textbbox((0, 0), name, font=font)
|
bbox = draw.textbbox((0, 0), name, font=font)
|
||||||
text_h = bbox[3] - bbox[1]
|
text_h = bbox[3] - bbox[1]
|
||||||
@@ -201,7 +179,8 @@ def _draw_face_label(img: Image.Image, draw: ImageDraw.ImageDraw, name: str, anc
|
|||||||
x0 = max(0, min(x0, img_w - w))
|
x0 = max(0, min(x0, img_w - w))
|
||||||
y0 = max(0, min(y0, img_h - h))
|
y0 = max(0, min(y0, img_h - h))
|
||||||
|
|
||||||
draw.rectangle([x0, y0, x0 + w, y0 + h], fill=(255, 255, 255), outline=(0, 0, 0))
|
draw.rounded_rectangle([x0, y0, x0 + w, y0 + h], radius=panel_style.CHIP_RADIUS,
|
||||||
|
fill=(255, 255, 255), outline=(0, 0, 0))
|
||||||
draw_text(img, (x0 + FACE_LABEL_PADDING, y0 + FACE_LABEL_PADDING - bbox[1]), name, font)
|
draw_text(img, (x0 + FACE_LABEL_PADDING, y0 + FACE_LABEL_PADDING - bbox[1]), name, font)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -798,6 +798,22 @@ def _migration_30(conn) -> None:
|
|||||||
conn.execute(text("ALTER TABLE frames ADD COLUMN last_displayed_at REAL NOT NULL DEFAULT 0.0"))
|
conn.execute(text("ALTER TABLE frames ADD COLUMN last_displayed_at REAL NOT NULL DEFAULT 0.0"))
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_31(conn) -> None:
|
||||||
|
"""Weather widget render style (models.WeatherWidgetConfig.
|
||||||
|
render_style): "classic" (existing hand-drawn PIL renderer,
|
||||||
|
unchanged) or "modern" (app/html_render.py's headless-Chromium/CSS
|
||||||
|
renderer). Every existing weather widget defaults to "classic" --
|
||||||
|
no behavior change until a widget's dialog switches it.
|
||||||
|
|
||||||
|
Guarded per-column, same reasoning as migration 30's own comment:
|
||||||
|
weather_widget_configs is a table some replay tests may re-create
|
||||||
|
fresh via create_all() (which already has this column) rather than
|
||||||
|
replaying migration 24's raw CREATE TABLE."""
|
||||||
|
existing = {c["name"] for c in inspect(conn).get_columns("weather_widget_configs")}
|
||||||
|
if "render_style" not in existing:
|
||||||
|
conn.execute(text("ALTER TABLE weather_widget_configs ADD COLUMN render_style TEXT NOT NULL DEFAULT 'classic'"))
|
||||||
|
|
||||||
|
|
||||||
MIGRATIONS = [
|
MIGRATIONS = [
|
||||||
(1, _migration_1),
|
(1, _migration_1),
|
||||||
(2, _migration_2),
|
(2, _migration_2),
|
||||||
@@ -829,6 +845,7 @@ MIGRATIONS = [
|
|||||||
(28, _migration_28),
|
(28, _migration_28),
|
||||||
(29, _migration_29),
|
(29, _migration_29),
|
||||||
(30, _migration_30),
|
(30, _migration_30),
|
||||||
|
(31, _migration_31),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -607,13 +607,19 @@ class WeatherWidgetConfig(Base):
|
|||||||
`cached`'s shape depends on `mode`: {"temp","category"} for current,
|
`cached`'s shape depends on `mode`: {"temp","category"} for current,
|
||||||
a list of {"time","temp","category"} for hourly, a
|
a list of {"time","temp","category"} for hourly, a
|
||||||
{"YYYY-MM-DD": {...}} dict for daily, or a list of
|
{"YYYY-MM-DD": {...}} dict for daily, or a list of
|
||||||
{"label","high","low","category"} for multi_city."""
|
{"label","high","low","category"} for multi_city.
|
||||||
|
`render_style` picks which renderer draws the widget: "classic" (the
|
||||||
|
hand-drawn PIL primitives in app/weather_render.py, unchanged
|
||||||
|
default) or "modern" (app/html_render.py's Jinja2/headless-Chromium
|
||||||
|
path, "current"/"daily" modes only for now -- see weather.py's
|
||||||
|
render())."""
|
||||||
|
|
||||||
__tablename__ = "weather_widget_configs"
|
__tablename__ = "weather_widget_configs"
|
||||||
|
|
||||||
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True)
|
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True)
|
||||||
mode: Mapped[str] = mapped_column(String, default="current") # current | hourly | daily | multi_city
|
mode: Mapped[str] = mapped_column(String, default="current") # current | hourly | daily | multi_city
|
||||||
provider: Mapped[str] = mapped_column(String, default="open_meteo") # open_meteo | nws
|
provider: Mapped[str] = mapped_column(String, default="open_meteo") # open_meteo | nws
|
||||||
|
render_style: Mapped[str] = mapped_column(String, default="classic") # classic | modern
|
||||||
units: Mapped[str] = mapped_column(String, default="fahrenheit") # fahrenheit | celsius
|
units: Mapped[str] = mapped_column(String, default="fahrenheit") # fahrenheit | celsius
|
||||||
# Single-location modes only (current/hourly/daily) -- geocoded once
|
# Single-location modes only (current/hourly/daily) -- geocoded once
|
||||||
# via weather.geocode_city() when set, same idiom as
|
# via weather.geocode_city() when set, same idiom as
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
"""Shared visual language for everything drawn onto the e-ink panel
|
||||||
|
(excluding widgets/text.py, which already has its own richer multi-
|
||||||
|
family font picker and is left alone) -- spacing, ink-color resolution,
|
||||||
|
Inter font loading, and the small set of drawing primitives
|
||||||
|
(header bar, color chip, battery icon) more than one render module needs.
|
||||||
|
|
||||||
|
Centralizes what used to be independently redefined per render file
|
||||||
|
(calendar_render.py/weather_render.py each had their own MARGIN/BG/FG/
|
||||||
|
RULE, widgets/battery.py and manage_overlay.py each had their own
|
||||||
|
battery-glyph-drawing code) so the panel reads as one consistent system
|
||||||
|
instead of N separately-styled widgets. Still bound by the same hard
|
||||||
|
constraints as everything else that draws before the single whole-canvas
|
||||||
|
quantize/dither pass (see image_pipeline.py's module docstring/draw_text):
|
||||||
|
every fill here is one of DEFAULT_PALETTE_RGB's 6 exact colors, and text
|
||||||
|
always routes through image_pipeline.draw_text.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
from .image_pipeline import DEFAULT_PALETTE_RGB
|
||||||
|
|
||||||
|
# Spacing scale. CONTENT_MARGIN carries over calendar_render.py/
|
||||||
|
# weather_render.py's own long-tuned MARGIN=20 value unchanged (not
|
||||||
|
# re-tuned -- every wrap/truncation-width calc in those modules was
|
||||||
|
# measured against it). GUTTER is new: the inset every widget applies
|
||||||
|
# within its own target_w x target_h box (see card_canvas) to get a
|
||||||
|
# visible seam between adjacent widgets without touching grid.py's
|
||||||
|
# zero-gap cell math.
|
||||||
|
GUTTER = 6
|
||||||
|
CONTENT_MARGIN = 20
|
||||||
|
CARD_RADIUS = 12
|
||||||
|
CHIP_RADIUS = 4
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
# Which accent ink each widget kind's chrome (header bar, task checkbox,
|
||||||
|
# etc.) uses -- one dict, so "what color is a calendar header" has a
|
||||||
|
# single answer instead of being hardcoded separately everywhere a
|
||||||
|
# render module wants it. This is what makes a future global color
|
||||||
|
# theme *possible* without another pass through every render module: a
|
||||||
|
# per-frame override just needs to pick a different THEME mapping (or
|
||||||
|
# remap individual entries) here and resolve through theme_color/ink
|
||||||
|
# below, which already goes through a frame's own tuned Frame.
|
||||||
|
# palette_rgb -- swapping a slot's actual RGB (e.g. a custom "blue")
|
||||||
|
# already re-themes every widget that uses THEME_CALENDAR for its
|
||||||
|
# header, with no other code to touch. Weather deliberately maps to
|
||||||
|
# BLACK, not a color -- see weather_render's header call site -- so its
|
||||||
|
# own hand-drawn, already-colorful icons stay the star.
|
||||||
|
THEME_CALENDAR = BLUE
|
||||||
|
THEME_TASKS = GREEN
|
||||||
|
THEME_WEATHER = BLACK
|
||||||
|
THEME = {"calendar": THEME_CALENDAR, "tasks": THEME_TASKS, "weather": THEME_WEATHER}
|
||||||
|
|
||||||
|
|
||||||
|
def theme_color(widget_kind: str, palette_rgb: list | None = None) -> tuple[int, int, int]:
|
||||||
|
"""THEME[widget_kind] resolved against this frame's actual palette --
|
||||||
|
the one call every render module's header/accent chrome should go
|
||||||
|
through instead of hardcoding a palette index inline."""
|
||||||
|
return ink(palette_rgb, THEME[widget_kind])
|
||||||
|
|
||||||
|
|
||||||
|
def ink(palette_rgb: list | None, index: int) -> tuple[int, int, int]:
|
||||||
|
"""One of this frame's actual panel colors by DEFAULT_PALETTE_RGB
|
||||||
|
index -- generalizes the same resolution idiom weather_render._ink/
|
||||||
|
calendar_render._event_colors already used locally, so a custom
|
||||||
|
palette override (Frame.palette_rgb) still gets its own actual
|
||||||
|
yellow/red/blue/green, and every fill stays an exact, ditherless
|
||||||
|
palette match either way."""
|
||||||
|
return tuple((palette_rgb or DEFAULT_PALETTE_RGB)[index])
|
||||||
|
|
||||||
|
|
||||||
|
_FONT_DIR = Path(__file__).resolve().parent / "fonts"
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=256)
|
||||||
|
def font_bold(size: int) -> ImageFont.FreeTypeFont:
|
||||||
|
return ImageFont.truetype(str(_FONT_DIR / "Inter-Bold.ttf"), size)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=256)
|
||||||
|
def font_regular(size: int) -> ImageFont.FreeTypeFont:
|
||||||
|
return ImageFont.truetype(str(_FONT_DIR / "Inter-Regular.ttf"), size)
|
||||||
|
|
||||||
|
|
||||||
|
def card_canvas(target_w: int, target_h: int,
|
||||||
|
bg: tuple[int, int, int] = (255, 255, 255)) -> tuple:
|
||||||
|
"""A full target_w x target_h canvas filled with `bg`, plus the
|
||||||
|
GUTTER-inset rect (x0, y0, w, h) every widget should draw its actual
|
||||||
|
chrome/content within -- this is the whole mechanism behind the
|
||||||
|
gutter between widgets (see module docstring): the widget's render()
|
||||||
|
contract (exact target_w x target_h in, same size out, unchanged) is
|
||||||
|
what routers/device.py pastes and what draw_widget_border frames, so
|
||||||
|
a border still frames the widget's true full box; only the widget's
|
||||||
|
own drawing backs off from that box's true edge."""
|
||||||
|
img = Image.new("RGB", (target_w, target_h), bg)
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
x0, y0 = GUTTER, GUTTER
|
||||||
|
w, h = max(1, target_w - 2 * GUTTER), max(1, target_h - 2 * GUTTER)
|
||||||
|
return img, draw, (x0, y0, w, h)
|
||||||
|
|
||||||
|
|
||||||
|
def _clamped_radius(radius: int, w: int, h: int) -> int:
|
||||||
|
return max(0, min(radius, w // 2, h // 2))
|
||||||
|
|
||||||
|
|
||||||
|
def draw_header_bar(draw: ImageDraw.ImageDraw, rect: tuple[int, int, int, int], height: int,
|
||||||
|
fill: tuple[int, int, int], radius: int = CARD_RADIUS) -> None:
|
||||||
|
"""A widget's title bar: rounded top corners only (corners=(tl, tr,
|
||||||
|
bl, br), the bottom pair left square) so it reads as a card's header
|
||||||
|
fused to the content below it, not a standalone pill floating with a
|
||||||
|
gap above its own body."""
|
||||||
|
x0, y0, w, h = rect
|
||||||
|
r = _clamped_radius(radius, w, height * 2)
|
||||||
|
draw.rounded_rectangle([x0, y0, x0 + w, y0 + height], radius=r, fill=fill,
|
||||||
|
corners=(True, True, False, False))
|
||||||
|
|
||||||
|
|
||||||
|
def draw_color_chip(draw: ImageDraw.ImageDraw, x0: int, y0: int, x1: int, y1: int,
|
||||||
|
colors: list[tuple[int, int, int]], radius: int = CHIP_RADIUS) -> None:
|
||||||
|
"""One rounded chip for a single-source event/task, or that same
|
||||||
|
footprint split into equal-width side-by-side segments -- one per
|
||||||
|
contributing calendar -- for a deduplicated shared event (see
|
||||||
|
calendar_render._event_colors/calendar_feed.merge_events). Splitting
|
||||||
|
rather than e.g. concentric rings keeps every color equally "thick
|
||||||
|
and bold" at a glance, the same design goal a single pinned color
|
||||||
|
already has. Generalizes calendar_render.py's old private
|
||||||
|
_draw_color_bar so the radius comes from one shared constant."""
|
||||||
|
if len(colors) == 1:
|
||||||
|
r = _clamped_radius(radius, x1 - x0, y1 - y0)
|
||||||
|
draw.rounded_rectangle([x0, y0, x1, y1], radius=r, fill=colors[0])
|
||||||
|
return
|
||||||
|
seg_w = (x1 - x0) / len(colors)
|
||||||
|
for i, color in enumerate(colors):
|
||||||
|
seg_x0 = round(x0 + i * seg_w)
|
||||||
|
seg_x1 = round(x0 + (i + 1) * seg_w) - (2 if i < len(colors) - 1 else 0)
|
||||||
|
draw.rectangle([seg_x0, y0, seg_x1, y1], fill=color)
|
||||||
|
|
||||||
|
|
||||||
|
def battery_fill_color(percent: int, palette_rgb: list | None = None) -> tuple[int, int, int]:
|
||||||
|
"""Red/yellow/green by charge level -- the fill itself carries the
|
||||||
|
"how worried should I be" signal, not just the number next to it.
|
||||||
|
Shared threshold logic for widgets/battery.py and manage_overlay.py,
|
||||||
|
which previously each defined the same three-tier thresholds twice."""
|
||||||
|
if percent <= 15:
|
||||||
|
return ink(palette_rgb, RED)
|
||||||
|
if percent <= 40:
|
||||||
|
return ink(palette_rgb, YELLOW)
|
||||||
|
return ink(palette_rgb, GREEN)
|
||||||
|
|
||||||
|
|
||||||
|
def draw_battery_icon(draw: ImageDraw.ImageDraw, x0: int, y0: int, icon_w: int, icon_h: int,
|
||||||
|
percent: int, palette_rgb: list | None = None) -> None:
|
||||||
|
"""A rounded battery glyph -- outline + charge-level fill + terminal
|
||||||
|
nub -- anchored at (x0, y0), the body's own top-left corner (the nub
|
||||||
|
extends past icon_w on the right). The one shared implementation
|
||||||
|
behind what used to be two separate ImageDraw glyphs: widgets/
|
||||||
|
battery.py's own icon+percent widget, and manage_overlay.py's compact
|
||||||
|
battery readout on the "scan to manage" overlay -- same shape, same
|
||||||
|
red/yellow/green thresholds, previously kept in sync by convention
|
||||||
|
rather than by sharing code."""
|
||||||
|
stroke = max(2, icon_h // 12)
|
||||||
|
nub_w = max(3, icon_w // 10)
|
||||||
|
nub_h = icon_h // 2
|
||||||
|
radius = _clamped_radius(icon_h // 6, icon_w, icon_h)
|
||||||
|
|
||||||
|
inner_x0, inner_y0 = x0 + stroke, y0 + stroke
|
||||||
|
inner_x1, inner_y1 = x0 + icon_w - stroke, y0 + icon_h - stroke
|
||||||
|
fill_x1 = inner_x0 + round((inner_x1 - inner_x0) * (max(0, min(100, percent)) / 100))
|
||||||
|
if fill_x1 > inner_x0:
|
||||||
|
fill_radius = _clamped_radius(radius, fill_x1 - inner_x0, inner_y1 - inner_y0)
|
||||||
|
draw.rounded_rectangle([inner_x0, inner_y0, fill_x1, inner_y1], radius=fill_radius,
|
||||||
|
fill=battery_fill_color(percent, palette_rgb))
|
||||||
|
draw.rounded_rectangle([x0, y0, x0 + icon_w, y0 + icon_h], radius=radius, outline=(0, 0, 0), width=stroke)
|
||||||
|
nub_y = y0 + (icon_h - nub_h) // 2
|
||||||
|
nub_radius = _clamped_radius(max(1, nub_w // 3), nub_w, nub_h)
|
||||||
|
draw.rounded_rectangle([x0 + icon_w, nub_y, x0 + icon_w + nub_w, nub_y + nub_h], radius=nub_radius,
|
||||||
|
fill=(0, 0, 0))
|
||||||
@@ -63,6 +63,10 @@ LAYOUT_CONFIG_FIELDS: dict[str, tuple[str, ...]] = {
|
|||||||
"text": ("content", "font_size", "font_family", "align", "background_color"),
|
"text": ("content", "font_size", "font_family", "align", "background_color"),
|
||||||
"whiteboard": ("user_id", "url"),
|
"whiteboard": ("user_id", "url"),
|
||||||
"battery": ("mode",),
|
"battery": ("mode",),
|
||||||
|
"weather": (
|
||||||
|
"mode", "provider", "units", "city_label", "city_latitude", "city_longitude",
|
||||||
|
"hourly_interval_hours", "daily_days", "cities", "render_style",
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
# widget_type -> (FrameCalendar|FrameTaskList model, SavedLayoutSource.kind)
|
# widget_type -> (FrameCalendar|FrameTaskList model, SavedLayoutSource.kind)
|
||||||
|
|||||||
@@ -341,6 +341,7 @@ def api_widget_config_save(
|
|||||||
weather_units: str | None = Form(None),
|
weather_units: str | None = Form(None),
|
||||||
weather_hourly_interval_hours: int | None = Form(None),
|
weather_hourly_interval_hours: int | None = Form(None),
|
||||||
weather_daily_days: int | None = Form(None),
|
weather_daily_days: int | None = Form(None),
|
||||||
|
weather_render_style: str | None = Form(None),
|
||||||
# battery
|
# battery
|
||||||
battery_mode: str | None = Form(None),
|
battery_mode: str | None = Form(None),
|
||||||
):
|
):
|
||||||
@@ -469,6 +470,8 @@ def api_widget_config_save(
|
|||||||
wcfg.hourly_interval_hours = max(1, min(24, weather_hourly_interval_hours))
|
wcfg.hourly_interval_hours = max(1, min(24, weather_hourly_interval_hours))
|
||||||
if weather_daily_days is not None:
|
if weather_daily_days is not None:
|
||||||
wcfg.daily_days = max(1, min(14, weather_daily_days))
|
wcfg.daily_days = max(1, min(14, weather_daily_days))
|
||||||
|
if weather_render_style is not None and weather_render_style in ("classic", "modern"):
|
||||||
|
wcfg.render_style = weather_render_style
|
||||||
elif widget.widget_type == "battery":
|
elif widget.widget_type == "battery":
|
||||||
with widget_locked(db, frame.id, widget.id) as (_, _, bcfg):
|
with widget_locked(db, frame.id, widget.id) as (_, _, bcfg):
|
||||||
if battery_mode is not None:
|
if battery_mode is not None:
|
||||||
@@ -1108,10 +1111,19 @@ def api_widget_preview_weather(
|
|||||||
if wcfg.mode == "multi_city":
|
if wcfg.mode == "multi_city":
|
||||||
raise HTTPException(400, "No cities added to this widget yet")
|
raise HTTPException(400, "No cities added to this widget yet")
|
||||||
raise HTTPException(400, "No location set on this widget yet")
|
raise HTTPException(400, "No location set on this widget yet")
|
||||||
png = weather_render.render_weather_preview_png(
|
if wcfg.render_style == "modern" and wcfg.mode in ("current", "daily"):
|
||||||
wcfg.mode, data, orientation=frame.orientation, palette_rgb=frame.palette_rgb, units=wcfg.units,
|
# Same local-import reasoning as widgets/weather.py's render().
|
||||||
city_label=wcfg.city_label or "", interval_hours=wcfg.hourly_interval_hours,
|
from .. import html_render
|
||||||
)
|
|
||||||
|
png = html_render.render_weather_preview_png(
|
||||||
|
wcfg.mode, data, orientation=frame.orientation, palette_rgb=frame.palette_rgb, units=wcfg.units,
|
||||||
|
city_label=wcfg.city_label or "",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
png = weather_render.render_weather_preview_png(
|
||||||
|
wcfg.mode, data, orientation=frame.orientation, palette_rgb=frame.palette_rgb, units=wcfg.units,
|
||||||
|
city_label=wcfg.city_label or "", interval_hours=wcfg.hourly_interval_hours,
|
||||||
|
)
|
||||||
return Response(content=png, media_type="image/png")
|
return Response(content=png, media_type="image/png")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from ..global_actions import GLOBAL_ACTION_LABELS
|
|||||||
from ..image_pipeline import (
|
from ..image_pipeline import (
|
||||||
BORDER_STYLES,
|
BORDER_STYLES,
|
||||||
BORDER_STYLE_LABELS,
|
BORDER_STYLE_LABELS,
|
||||||
|
CALIBRATED_SPECTRA6_RGB,
|
||||||
DEFAULT_PALETTE_RGB,
|
DEFAULT_PALETTE_RGB,
|
||||||
DISPLAY_MODE_LABELS,
|
DISPLAY_MODE_LABELS,
|
||||||
MAX_BORDER_THICKNESS,
|
MAX_BORDER_THICKNESS,
|
||||||
@@ -89,6 +90,7 @@ def frame_config_page(frame_id: int, request: Request, db: Session = Depends(get
|
|||||||
timezones=ALL_TIMEZONES,
|
timezones=ALL_TIMEZONES,
|
||||||
palette_labels=PALETTE_LABELS,
|
palette_labels=PALETTE_LABELS,
|
||||||
default_palette_rgb=DEFAULT_PALETTE_RGB,
|
default_palette_rgb=DEFAULT_PALETTE_RGB,
|
||||||
|
calibrated_spectra6_hex=palette_to_hex(CALIBRATED_SPECTRA6_RGB),
|
||||||
palette_to_hex=palette_to_hex,
|
palette_to_hex=palette_to_hex,
|
||||||
photo_widget_id=photo_widget_id,
|
photo_widget_id=photo_widget_id,
|
||||||
global_action_labels=GLOBAL_ACTION_LABELS,
|
global_action_labels=GLOBAL_ACTION_LABELS,
|
||||||
|
|||||||
@@ -215,6 +215,18 @@ document.getElementById('palette-reset').addEventListener('click', () => {
|
|||||||
savePalette({ palette_reset: 'true', color_boost: '1', contrast_boost: '1', dither_strength: '1' });
|
savePalette({ palette_reset: 'true', color_boost: '1', contrast_boost: '1', dither_strength: '1' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fills the table with a community-measured starting point (see the
|
||||||
|
// card's own explanatory text) -- doesn't save by itself, same as
|
||||||
|
// editing the hex/RGB fields by hand; the user still clicks Save (or
|
||||||
|
// Reset) to commit or discard it.
|
||||||
|
document.getElementById('palette-load-calibrated').addEventListener('click', () => {
|
||||||
|
const inputs = paletteHexInputs();
|
||||||
|
window.CALIBRATED_SPECTRA6_HEX.forEach((hex, i) => {
|
||||||
|
inputs[i].value = hex;
|
||||||
|
syncPaletteFromHex(i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ---- Preview: current photo vs. how it renders with saved settings ----
|
// ---- Preview: current photo vs. how it renders with saved settings ----
|
||||||
// Scoped to this frame's photo widget (window.PHOTO_WIDGET_PREVIEW_API,
|
// Scoped to this frame's photo widget (window.PHOTO_WIDGET_PREVIEW_API,
|
||||||
// set by the template) rather than window.FRAME_API -- palette/color/
|
// set by the template) rather than window.FRAME_API -- palette/color/
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ function updateWeatherFieldVisibility() {
|
|||||||
document.getElementById('weather-daily-days-row').style.display = mode === 'daily' ? '' : 'none';
|
document.getElementById('weather-daily-days-row').style.display = mode === 'daily' ? '' : 'none';
|
||||||
document.getElementById('weather-location-section').style.display = mode === 'multi_city' ? 'none' : '';
|
document.getElementById('weather-location-section').style.display = mode === 'multi_city' ? 'none' : '';
|
||||||
document.getElementById('weather-cities-section').style.display = mode === 'multi_city' ? '' : 'none';
|
document.getElementById('weather-cities-section').style.display = mode === 'multi_city' ? '' : 'none';
|
||||||
|
// Modern style is only built for current/daily (see app/html_render.py) --
|
||||||
|
// hourly/multi_city always render classic server-side regardless of this
|
||||||
|
// setting, so hide the row entirely rather than offer a choice that's a
|
||||||
|
// silent no-op.
|
||||||
|
document.getElementById('weather-render-style-row').style.display =
|
||||||
|
(mode === 'current' || mode === 'daily') ? '' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
function addWeatherWidgetCityRow(label) {
|
function addWeatherWidgetCityRow(label) {
|
||||||
@@ -74,6 +80,7 @@ function initWeatherDialog() {
|
|||||||
weather_units: document.getElementById('weather_units').value,
|
weather_units: document.getElementById('weather_units').value,
|
||||||
weather_hourly_interval_hours: document.getElementById('weather_hourly_interval_hours').value,
|
weather_hourly_interval_hours: document.getElementById('weather_hourly_interval_hours').value,
|
||||||
weather_daily_days: document.getElementById('weather_daily_days').value,
|
weather_daily_days: document.getElementById('weather_daily_days').value,
|
||||||
|
weather_render_style: document.getElementById('weather_render_style').value,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`${window.FRAME_API}/config`, {
|
const resp = await fetch(`${window.FRAME_API}/config`, {
|
||||||
|
|||||||
@@ -11,6 +11,14 @@
|
|||||||
<option value="multi_city" {% if weather_cfg.mode == "multi_city" %}selected{% endif %}>Multiple cities</option>
|
<option value="multi_city" {% if weather_cfg.mode == "multi_city" %}selected{% endif %}>Multiple cities</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<div id="weather-render-style-row">
|
||||||
|
<label>Render style
|
||||||
|
<select id="weather_render_style">
|
||||||
|
<option value="classic" {% if weather_cfg.render_style == "classic" %}selected{% endif %}>Classic (hand-drawn icons)</option>
|
||||||
|
<option value="modern" {% if weather_cfg.render_style == "modern" %}selected{% endif %}>Modern (experimental, current/daily only)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<label>Weather source
|
<label>Weather source
|
||||||
<select id="weather_provider">
|
<select id="weather_provider">
|
||||||
{% for value, label in weather_provider_labels.items() %}
|
{% for value, label in weather_provider_labels.items() %}
|
||||||
|
|||||||
@@ -187,6 +187,12 @@
|
|||||||
|
|
||||||
<button type="button" class="secondary" id="palette-save" style="margin-top: 16px;">Save</button>
|
<button type="button" class="secondary" id="palette-save" style="margin-top: 16px;">Save</button>
|
||||||
<button type="button" class="secondary" id="palette-reset">Reset to defaults</button>
|
<button type="button" class="secondary" id="palette-reset">Reset to defaults</button>
|
||||||
|
<button type="button" class="secondary" id="palette-load-calibrated">Load calibrated Spectra 6 preset</button>
|
||||||
|
<p class="sub" style="margin-top: 8px;">Experimental: a community-measured
|
||||||
|
starting point (not this specific panel) -- fills the table above,
|
||||||
|
doesn't save by itself. Real Spectra 6 ink is duller than the
|
||||||
|
idealized defaults; this may or may not match your actual unit.
|
||||||
|
Compare against the physical panel before keeping it.</p>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
@@ -221,6 +227,7 @@
|
|||||||
<script>
|
<script>
|
||||||
window.FRAME_BASE_API = window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};
|
window.FRAME_BASE_API = window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};
|
||||||
window.DEFAULT_PALETTE_HEX = {{ palette_to_hex(default_palette_rgb) | tojson }};
|
window.DEFAULT_PALETTE_HEX = {{ palette_to_hex(default_palette_rgb) | tojson }};
|
||||||
|
window.CALIBRATED_SPECTRA6_HEX = {{ calibrated_spectra6_hex | tojson }};
|
||||||
window.PHOTO_WIDGET_PREVIEW_API = {{ (("/api/frames/" ~ frame.id ~ "/widgets/" ~ photo_widget_id) | tojson) if photo_widget_id else "null" }};
|
window.PHOTO_WIDGET_PREVIEW_API = {{ (("/api/frames/" ~ frame.id ~ "/widgets/" ~ photo_widget_id) | tojson) if photo_widget_id else "null" }};
|
||||||
</script>
|
</script>
|
||||||
<script src="/static/device_status_bar.js"></script>
|
<script src="/static/device_status_bar.js"></script>
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html><head><style>
|
||||||
|
@font-face { font-family: "Inter"; src: url("file://{{ font_dir }}/Inter-Regular.ttf"); font-weight: 400; }
|
||||||
|
@font-face { font-family: "Inter"; src: url("file://{{ font_dir }}/Inter-Bold.ttf"); font-weight: 700; }
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "Inter", sans-serif; }
|
||||||
|
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
|
||||||
|
.card {
|
||||||
|
width: {{ w - gutter * 2 }}px;
|
||||||
|
height: {{ h - gutter * 2 }}px;
|
||||||
|
margin: {{ gutter }}px;
|
||||||
|
border-radius: {{ radius }}px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #ffffff;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.icon { font-size: {{ icon_size }}px; line-height: 1; filter: drop-shadow(0 3px 4px rgba(0, 0, 0, 0.18)); }
|
||||||
|
.temp { font-weight: 700; font-size: {{ temp_size }}px; color: #17233b; }
|
||||||
|
.city { font-weight: 400; font-size: {{ label_size }}px; color: #5b6674; }
|
||||||
|
</style></head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="icon">{{ emoji }}</div>
|
||||||
|
<div class="temp">{{ temp }}°{{ unit_suffix }}</div>
|
||||||
|
{% if city_label %}<div class="city">{{ city_label }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
</body></html>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html><head><style>
|
||||||
|
@font-face { font-family: "Inter"; src: url("file://{{ font_dir }}/Inter-Regular.ttf"); font-weight: 400; }
|
||||||
|
@font-face { font-family: "Inter"; src: url("file://{{ font_dir }}/Inter-Bold.ttf"); font-weight: 700; }
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "Inter", sans-serif; }
|
||||||
|
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
|
||||||
|
.card {
|
||||||
|
width: {{ w - gutter * 2 }}px;
|
||||||
|
height: {{ h - gutter * 2 }}px;
|
||||||
|
margin: {{ gutter }}px;
|
||||||
|
border-radius: {{ radius }}px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4px 14px rgba(0, 20, 60, 0.25);
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
height: {{ header_h }}px;
|
||||||
|
background: linear-gradient(135deg, {{ accent_start }} 0%, {{ accent_end }} 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
.header .title { color: #ffffff; font-weight: 700; font-size: {{ title_size }}px; }
|
||||||
|
.body { display: flex; height: calc(100% - {{ header_h }}px); padding: 12px 8px; }
|
||||||
|
.col {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
.day { font-weight: 600; font-size: {{ label_size }}px; color: #17233b; }
|
||||||
|
.icon { font-size: {{ icon_size }}px; line-height: 1; }
|
||||||
|
.temps { font-weight: 700; font-size: {{ label_size }}px; color: #17233b; }
|
||||||
|
.temps .low { color: #6b7788; font-weight: 400; }
|
||||||
|
</style></head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
{% if city_label %}<div class="header"><div class="title">{{ city_label }}</div></div>{% endif %}
|
||||||
|
<div class="body">
|
||||||
|
{% for d in days %}
|
||||||
|
<div class="col">
|
||||||
|
<div class="day">{{ d.label }}</div>
|
||||||
|
<div class="icon">{{ d.emoji }}</div>
|
||||||
|
<div class="temps">{{ d.high }}°<span class="low">/{{ d.low }}°{{ unit_suffix }}</span></div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body></html>
|
||||||
@@ -33,23 +33,27 @@ from datetime import date, datetime
|
|||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
from .image_pipeline import DEFAULT_PALETTE_RGB, _apply_manage_overlay, _quantize, draw_text, logical_render_size
|
from . import panel_style
|
||||||
|
from .image_pipeline import _apply_manage_overlay, _quantize, draw_text, logical_render_size
|
||||||
|
|
||||||
MARGIN = 20
|
# MARGIN carries panel_style.CONTENT_MARGIN's value unchanged (not
|
||||||
|
# re-tuned -- every column-width/icon-size calc below was measured
|
||||||
|
# against 20px). BG/FG are this module's own cloud-icon fill/outline and
|
||||||
|
# fog-line color (see draw_cloud/draw_weather_icon), not a text-emphasis
|
||||||
|
# concern -- those live in panel_style (font_bold/font_regular, no MUTED
|
||||||
|
# gray -- see its module docstring for why).
|
||||||
|
MARGIN = panel_style.CONTENT_MARGIN
|
||||||
BG = (255, 255, 255)
|
BG = (255, 255, 255)
|
||||||
FG = (0, 0, 0)
|
FG = (0, 0, 0)
|
||||||
MUTED = (110, 110, 110)
|
|
||||||
RULE = (0, 0, 0)
|
|
||||||
|
|
||||||
|
|
||||||
def _ink(palette_rgb: list | None, index: int) -> tuple[int, int, int]:
|
def _ink(palette_rgb: list | None, index: int) -> tuple[int, int, int]:
|
||||||
"""One of this frame's actual panel colors by DEFAULT_PALETTE_RGB
|
"""One of this frame's actual panel colors by DEFAULT_PALETTE_RGB
|
||||||
index (2=yellow, 3=red, 4=blue, 5=green -- 0/1 are black/white,
|
index (2=yellow, 3=red, 4=blue, 5=green -- 0/1 are black/white,
|
||||||
already this module's BG/FG) -- same resolution idiom as
|
already this module's BG/FG) -- thin wrapper over panel_style.ink
|
||||||
calendar_render.py's _event_colors, so a custom palette override
|
(which generalized this same resolution idiom), kept so every
|
||||||
(Frame.palette_rgb) still gets its own actual yellow/blue, and every
|
draw_weather_icon call site below doesn't need touching."""
|
||||||
fill stays an exact, ditherless palette match either way."""
|
return panel_style.ink(palette_rgb, index)
|
||||||
return tuple((palette_rgb or DEFAULT_PALETTE_RGB)[index])
|
|
||||||
|
|
||||||
|
|
||||||
def draw_cloud(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float, fill=BG, outline=FG) -> None:
|
def draw_cloud(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float, fill=BG, outline=FG) -> None:
|
||||||
@@ -203,14 +207,17 @@ def _format_hour_label(iso_time: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _fit_font_size(draw: ImageDraw.ImageDraw, texts: list[str], max_width: int, max_size: int,
|
def _fit_font_size(draw: ImageDraw.ImageDraw, texts: list[str], max_width: int, max_size: int,
|
||||||
min_size: int = 9) -> int:
|
min_size: int = 9, font_loader=panel_style.font_bold) -> int:
|
||||||
"""Largest size <= max_size at which every string in `texts` fits
|
"""Largest size <= max_size at which every string in `texts` fits
|
||||||
within max_width -- used to size a per-column label/temp font against
|
within max_width -- used to size a per-column label/temp font against
|
||||||
the actual column width instead of an icon-radius-derived guess,
|
the actual column width instead of an icon-radius-derived guess,
|
||||||
which (e.g. "Tomorrow" vs. "Wed") let long labels overlap into the
|
which (e.g. "Tomorrow" vs. "Wed") let long labels overlap into the
|
||||||
next column at a large icon size on a narrow column."""
|
next column at a large icon size on a narrow column. Measured against
|
||||||
|
`font_loader` (default Inter Bold -- the wider of the two weights a
|
||||||
|
column actually mixes, a label in Regular and a temp in Bold, so
|
||||||
|
fitting against Bold keeps both safely inside max_width)."""
|
||||||
for size in range(max_size, min_size - 1, -1):
|
for size in range(max_size, min_size - 1, -1):
|
||||||
font = ImageFont.load_default(size=size)
|
font = font_loader(size)
|
||||||
if all(draw.textlength(t, font=font) <= max_width for t in texts):
|
if all(draw.textlength(t, font=font) <= max_width for t in texts):
|
||||||
return size
|
return size
|
||||||
return min_size
|
return min_size
|
||||||
@@ -232,29 +239,28 @@ def build_current(entry: dict | None, target_w: int, target_h: int, palette_rgb:
|
|||||||
(callers normally catch that earlier and show a placeholder instead,
|
(callers normally catch that earlier and show a placeholder instead,
|
||||||
but this degrades to a blank canvas rather than erroring either
|
but this degrades to a blank canvas rather than erroring either
|
||||||
way)."""
|
way)."""
|
||||||
img = Image.new("RGB", (target_w, target_h), BG)
|
img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h)
|
||||||
if not entry:
|
if not entry:
|
||||||
return img
|
return img
|
||||||
draw = ImageDraw.Draw(img)
|
|
||||||
|
|
||||||
icon_r = max(20, min(target_w, target_h) // 4)
|
icon_r = max(20, min(cw, ch) // 4)
|
||||||
cx, cy = target_w // 2, target_h // 2 - icon_r // 2
|
cx, cy = cx0 + cw // 2, cy0 + ch // 2 - icon_r // 2
|
||||||
draw_weather_icon(draw, cx, cy, icon_r, entry["category"], palette_rgb)
|
draw_weather_icon(draw, cx, cy, icon_r, entry["category"], palette_rgb)
|
||||||
|
|
||||||
unit_suffix = "F" if units == "fahrenheit" else "C"
|
unit_suffix = "F" if units == "fahrenheit" else "C"
|
||||||
temp_size = max(24, min(target_w, target_h) // 3)
|
temp_size = max(24, min(cw, ch) // 3)
|
||||||
temp_font = ImageFont.load_default(size=temp_size)
|
temp_font = panel_style.font_bold(temp_size)
|
||||||
temp_text = f"{round(entry['temp'])}°{unit_suffix}"
|
temp_text = f"{round(entry['temp'])}°{unit_suffix}"
|
||||||
bbox = draw.textbbox((0, 0), temp_text, font=temp_font)
|
bbox = draw.textbbox((0, 0), temp_text, font=temp_font)
|
||||||
temp_y = cy + icon_r + 12
|
temp_y = cy + icon_r + 12
|
||||||
draw_text(img, (target_w // 2 - (bbox[2] - bbox[0]) // 2, temp_y), temp_text, temp_font)
|
draw_text(img, (cx0 + cw // 2 - (bbox[2] - bbox[0]) // 2, temp_y), temp_text, temp_font)
|
||||||
|
|
||||||
if city_label:
|
if city_label:
|
||||||
label_size = max(12, temp_size // 3)
|
label_size = max(12, temp_size // 3)
|
||||||
label_font = ImageFont.load_default(size=label_size)
|
label_font = panel_style.font_regular(label_size)
|
||||||
lbbox = draw.textbbox((0, 0), city_label, font=label_font)
|
lbbox = draw.textbbox((0, 0), city_label, font=label_font)
|
||||||
draw_text(img, (target_w // 2 - (lbbox[2] - lbbox[0]) // 2, temp_y + temp_size + 8),
|
draw_text(img, (cx0 + cw // 2 - (lbbox[2] - lbbox[0]) // 2, temp_y + temp_size + 8),
|
||||||
city_label, label_font, MUTED)
|
city_label, label_font)
|
||||||
return img
|
return img
|
||||||
|
|
||||||
|
|
||||||
@@ -265,19 +271,23 @@ def build_hourly(entries: list[dict], target_w: int, target_h: int, palette_rgb:
|
|||||||
fetch_hourly), each showing an hour label, icon, and temp. Same
|
fetch_hourly), each showing an hour label, icon, and temp. Same
|
||||||
"draw however many fit" graceful degradation as draw_weather_row if
|
"draw however many fit" graceful degradation as draw_weather_row if
|
||||||
the box is too narrow for every tick."""
|
the box is too narrow for every tick."""
|
||||||
img = Image.new("RGB", (target_w, target_h), BG)
|
img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h)
|
||||||
draw = ImageDraw.Draw(img)
|
text_x0 = cx0 + MARGIN
|
||||||
text_x0 = MARGIN
|
text_w = cw - MARGIN * 2
|
||||||
text_w = target_w - MARGIN * 2
|
y = cy0 + MARGIN
|
||||||
y = MARGIN
|
|
||||||
|
|
||||||
title_size = max(14, min(target_w, target_h) // 16)
|
title_size = max(14, min(cw, ch) // 16)
|
||||||
if city_label:
|
if city_label:
|
||||||
title_font = ImageFont.load_default(size=title_size)
|
# A filled header bar (this widget's chosen accent is black, not
|
||||||
draw_text(img, (text_x0, y), city_label, title_font)
|
# a color, so the hand-drawn icons below stay the star -- see
|
||||||
y += title_size + 10
|
# panel_style module docstring) replaces the old plain title +
|
||||||
draw.line([(text_x0, y), (text_x0 + text_w, y)], fill=RULE)
|
# thin rule line.
|
||||||
y += 12
|
header_h = title_size + 20
|
||||||
|
panel_style.draw_header_bar(draw, (cx0, cy0, cw, header_h), header_h,
|
||||||
|
panel_style.theme_color("weather", palette_rgb))
|
||||||
|
title_font = panel_style.font_bold(title_size)
|
||||||
|
draw_text(img, (text_x0, cy0 + (header_h - title_size) // 2), city_label, title_font, BG)
|
||||||
|
y = cy0 + header_h + 12
|
||||||
|
|
||||||
# Capped to however many columns actually fit at a legible width
|
# Capped to however many columns actually fit at a legible width
|
||||||
# (narrower widgets/smaller intervals just show fewer ticks) rather
|
# (narrower widgets/smaller intervals just show fewer ticks) rather
|
||||||
@@ -290,23 +300,24 @@ def build_hourly(entries: list[dict], target_w: int, target_h: int, palette_rgb:
|
|||||||
if not ticks:
|
if not ticks:
|
||||||
return img
|
return img
|
||||||
col_w = max(1, text_w // len(ticks))
|
col_w = max(1, text_w // len(ticks))
|
||||||
icon_r = max(10, min(col_w // 3, (target_h - y - MARGIN) // 4))
|
icon_r = max(10, min(col_w // 3, (cy0 + ch - y - MARGIN) // 4))
|
||||||
unit_suffix = "F" if units == "fahrenheit" else "C"
|
unit_suffix = "F" if units == "fahrenheit" else "C"
|
||||||
time_labels = [_format_hour_label(e["time"]) for e in ticks]
|
time_labels = [_format_hour_label(e["time"]) for e in ticks]
|
||||||
temp_labels = [f"{round(e['temp'])}°{unit_suffix}" for e in ticks]
|
temp_labels = [f"{round(e['temp'])}°{unit_suffix}" for e in ticks]
|
||||||
label_size = _fit_font_size(draw, time_labels + temp_labels, col_w - 6, max_size=icon_r)
|
label_size = _fit_font_size(draw, time_labels + temp_labels, col_w - 6, max_size=icon_r)
|
||||||
label_font = ImageFont.load_default(size=label_size)
|
time_font = panel_style.font_regular(label_size)
|
||||||
|
temp_font = panel_style.font_bold(label_size)
|
||||||
|
|
||||||
for i, entry in enumerate(ticks):
|
for i, entry in enumerate(ticks):
|
||||||
cx = text_x0 + i * col_w + col_w // 2
|
cx = text_x0 + i * col_w + col_w // 2
|
||||||
time_label = time_labels[i]
|
time_label = time_labels[i]
|
||||||
tbbox = draw.textbbox((0, 0), time_label, font=label_font)
|
tbbox = draw.textbbox((0, 0), time_label, font=time_font)
|
||||||
draw_text(img, (cx - (tbbox[2] - tbbox[0]) // 2, y), time_label, label_font, MUTED)
|
draw_text(img, (cx - (tbbox[2] - tbbox[0]) // 2, y), time_label, time_font)
|
||||||
cy = y + label_size + 10 + icon_r
|
cy = y + label_size + 10 + icon_r
|
||||||
draw_weather_icon(draw, cx, cy, icon_r, entry["category"], palette_rgb)
|
draw_weather_icon(draw, cx, cy, icon_r, entry["category"], palette_rgb)
|
||||||
temp_label = temp_labels[i]
|
temp_label = temp_labels[i]
|
||||||
tempbbox = draw.textbbox((0, 0), temp_label, font=label_font)
|
tempbbox = draw.textbbox((0, 0), temp_label, font=temp_font)
|
||||||
draw_text(img, (cx - (tempbbox[2] - tempbbox[0]) // 2, cy + icon_r + 6), temp_label, label_font)
|
draw_text(img, (cx - (tempbbox[2] - tempbbox[0]) // 2, cy + icon_r + 6), temp_label, temp_font)
|
||||||
return img
|
return img
|
||||||
|
|
||||||
|
|
||||||
@@ -317,40 +328,41 @@ def build_daily(daily: dict[str, dict], target_w: int, target_h: int, palette_rg
|
|||||||
configured day count by app/weather's provider fetch_daily -- this
|
configured day count by app/weather's provider fetch_daily -- this
|
||||||
just draws whatever it's handed, same "stop once it doesn't fit"
|
just draws whatever it's handed, same "stop once it doesn't fit"
|
||||||
graceful degradation as draw_weather_row)."""
|
graceful degradation as draw_weather_row)."""
|
||||||
img = Image.new("RGB", (target_w, target_h), BG)
|
img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h)
|
||||||
draw = ImageDraw.Draw(img)
|
text_x0 = cx0 + MARGIN
|
||||||
text_x0 = MARGIN
|
text_w = cw - MARGIN * 2
|
||||||
text_w = target_w - MARGIN * 2
|
y = cy0 + MARGIN
|
||||||
y = MARGIN
|
|
||||||
|
|
||||||
title_size = max(14, min(target_w, target_h) // 16)
|
title_size = max(14, min(cw, ch) // 16)
|
||||||
if city_label:
|
if city_label:
|
||||||
title_font = ImageFont.load_default(size=title_size)
|
header_h = title_size + 20
|
||||||
draw_text(img, (text_x0, y), city_label, title_font)
|
panel_style.draw_header_bar(draw, (cx0, cy0, cw, header_h), header_h,
|
||||||
y += title_size + 10
|
panel_style.theme_color("weather", palette_rgb))
|
||||||
draw.line([(text_x0, y), (text_x0 + text_w, y)], fill=RULE)
|
title_font = panel_style.font_bold(title_size)
|
||||||
y += 12
|
draw_text(img, (text_x0, cy0 + (header_h - title_size) // 2), city_label, title_font, BG)
|
||||||
|
y = cy0 + header_h + 12
|
||||||
|
|
||||||
days = list(daily.items())
|
days = list(daily.items())
|
||||||
if not days:
|
if not days:
|
||||||
return img
|
return img
|
||||||
col_w = max(1, text_w // len(days))
|
col_w = max(1, text_w // len(days))
|
||||||
icon_r = max(12, min(col_w // 3, (target_h - y - MARGIN) // 4))
|
icon_r = max(12, min(col_w // 3, (cy0 + ch - y - MARGIN) // 4))
|
||||||
unit_suffix = "F" if units == "fahrenheit" else "C"
|
unit_suffix = "F" if units == "fahrenheit" else "C"
|
||||||
labels = [_day_label(date.fromisoformat(day_str)) for day_str, _ in days]
|
labels = [_day_label(date.fromisoformat(day_str)) for day_str, _ in days]
|
||||||
temps_strs = [f"{round(d['high'])}°/{round(d['low'])}°{unit_suffix}" for _, d in days]
|
temps_strs = [f"{round(d['high'])}°/{round(d['low'])}°{unit_suffix}" for _, d in days]
|
||||||
label_size = _fit_font_size(draw, labels + temps_strs, col_w - 6, max_size=icon_r)
|
label_size = _fit_font_size(draw, labels + temps_strs, col_w - 6, max_size=icon_r)
|
||||||
label_font = ImageFont.load_default(size=label_size)
|
label_font = panel_style.font_regular(label_size)
|
||||||
|
temp_font = panel_style.font_bold(label_size)
|
||||||
|
|
||||||
for i, (_, d) in enumerate(days):
|
for i, (_, d) in enumerate(days):
|
||||||
x0 = text_x0 + i * col_w
|
x0 = text_x0 + i * col_w
|
||||||
label, temps = labels[i], temps_strs[i]
|
label, temps = labels[i], temps_strs[i]
|
||||||
lbbox = draw.textbbox((0, 0), label, font=label_font)
|
lbbox = draw.textbbox((0, 0), label, font=label_font)
|
||||||
draw_text(img, (x0 + col_w // 2 - (lbbox[2] - lbbox[0]) // 2, y), label, label_font, MUTED)
|
draw_text(img, (x0 + col_w // 2 - (lbbox[2] - lbbox[0]) // 2, y), label, label_font)
|
||||||
cx, cy = x0 + col_w // 2, y + label_size + 10 + icon_r
|
cx, cy = x0 + col_w // 2, y + label_size + 10 + icon_r
|
||||||
draw_weather_icon(draw, cx, cy, icon_r, d["category"], palette_rgb)
|
draw_weather_icon(draw, cx, cy, icon_r, d["category"], palette_rgb)
|
||||||
tbbox = draw.textbbox((0, 0), temps, font=label_font)
|
tbbox = draw.textbbox((0, 0), temps, font=temp_font)
|
||||||
draw_text(img, (cx - (tbbox[2] - tbbox[0]) // 2, cy + icon_r + 8), temps, label_font)
|
draw_text(img, (cx - (tbbox[2] - tbbox[0]) // 2, cy + icon_r + 8), temps, temp_font)
|
||||||
return img
|
return img
|
||||||
|
|
||||||
|
|
||||||
@@ -360,10 +372,9 @@ def build_multi_city(cities: list[dict], target_w: int, target_h: int, palette_r
|
|||||||
reuses draw_weather_row (the same layout calendar_render.py's
|
reuses draw_weather_row (the same layout calendar_render.py's
|
||||||
embedded strip uses), just as the whole widget's own content instead
|
embedded strip uses), just as the whole widget's own content instead
|
||||||
of a strip above an agenda day."""
|
of a strip above an agenda day."""
|
||||||
img = Image.new("RGB", (target_w, target_h), BG)
|
img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h)
|
||||||
if not cities:
|
if not cities:
|
||||||
return img
|
return img
|
||||||
draw = ImageDraw.Draw(img)
|
|
||||||
# Just the city name on-panel ("Portland", not the full disambiguated
|
# Just the city name on-panel ("Portland", not the full disambiguated
|
||||||
# "Portland, Oregon, United States") -- that fuller form matters for
|
# "Portland, Oregon, United States") -- that fuller form matters for
|
||||||
# telling apart geocoder candidates when adding a city (see
|
# telling apart geocoder candidates when adding a city (see
|
||||||
@@ -372,7 +383,7 @@ def build_multi_city(cities: list[dict], target_w: int, target_h: int, palette_r
|
|||||||
# calendar_render.py's _weather_for_day already does for its own
|
# calendar_render.py's _weather_for_day already does for its own
|
||||||
# embedded strip.
|
# embedded strip.
|
||||||
cities = [{**c, "label": c["label"].split(",")[0].strip()} for c in cities]
|
cities = [{**c, "label": c["label"].split(",")[0].strip()} for c in cities]
|
||||||
text_w = target_w - MARGIN * 2
|
text_w = cw - MARGIN * 2
|
||||||
# Sized against how many entries actually need to fit side by side,
|
# Sized against how many entries actually need to fit side by side,
|
||||||
# not just the box's height -- an icon/font picked from target_h
|
# not just the box's height -- an icon/font picked from target_h
|
||||||
# alone (as this used to do) drew each entry so wide that only the
|
# alone (as this used to do) drew each entry so wide that only the
|
||||||
@@ -380,13 +391,14 @@ def build_multi_city(cities: list[dict], target_w: int, target_h: int, palette_r
|
|||||||
# doesn't fit" degradation silently dropped every city after it,
|
# doesn't fit" degradation silently dropped every city after it,
|
||||||
# even in an ordinary-sized widget with plenty of cities configured.
|
# even in an ordinary-sized widget with plenty of cities configured.
|
||||||
col_w = max(1, text_w // len(cities))
|
col_w = max(1, text_w // len(cities))
|
||||||
icon_r = max(10, min(col_w // 6, target_h // 6, 40))
|
icon_r = max(10, min(col_w // 6, ch // 6, 40))
|
||||||
unit_suffix = "F" if units == "fahrenheit" else "C"
|
unit_suffix = "F" if units == "fahrenheit" else "C"
|
||||||
labels = [f"{c['label']} {round(c['high'])}°/{round(c['low'])}°{unit_suffix}" for c in cities]
|
labels = [f"{c['label']} {round(c['high'])}°/{round(c['low'])}°{unit_suffix}" for c in cities]
|
||||||
font_size = _fit_font_size(draw, labels, col_w - (icon_r * 2 + 24), max_size=icon_r)
|
font_size = _fit_font_size(draw, labels, col_w - (icon_r * 2 + 24), max_size=icon_r,
|
||||||
font = ImageFont.load_default(size=font_size)
|
font_loader=panel_style.font_regular)
|
||||||
y = max(MARGIN, (target_h - (icon_r * 2 + 8)) // 2)
|
font = panel_style.font_regular(font_size)
|
||||||
draw_weather_row(img, draw, MARGIN, y, text_w, cities, icon_r=icon_r, font=font, units=units,
|
y = max(cy0 + MARGIN, cy0 + (ch - (icon_r * 2 + 8)) // 2)
|
||||||
|
draw_weather_row(img, draw, cx0 + MARGIN, y, text_w, cities, icon_r=icon_r, font=font, units=units,
|
||||||
show_labels=True, palette_rgb=palette_rgb)
|
show_labels=True, palette_rgb=palette_rgb)
|
||||||
return img
|
return img
|
||||||
|
|
||||||
|
|||||||
@@ -7,17 +7,19 @@ fraction of the panel, so its placeholder needs to scale down with it.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
_BG = (245, 245, 245)
|
from .. import panel_style
|
||||||
_FG = (90, 90, 90)
|
from ..image_pipeline import draw_text
|
||||||
|
|
||||||
|
_BG = (255, 255, 255)
|
||||||
|
|
||||||
|
|
||||||
def placeholder_image(target_w: int, target_h: int, lines: list[str]) -> Image.Image:
|
def placeholder_image(target_w: int, target_h: int, lines: list[str]) -> Image.Image:
|
||||||
img = Image.new("RGB", (target_w, target_h), _BG)
|
img = Image.new("RGB", (target_w, target_h), _BG)
|
||||||
draw = ImageDraw.Draw(img)
|
draw = ImageDraw.Draw(img)
|
||||||
font_size = max(10, min(20, target_h // 8))
|
font_size = max(10, min(20, target_h // 8))
|
||||||
font = ImageFont.load_default(size=font_size)
|
font = panel_style.font_regular(font_size)
|
||||||
line_h = font_size + 4
|
line_h = font_size + 4
|
||||||
total_h = line_h * len(lines)
|
total_h = line_h * len(lines)
|
||||||
y = max(4, (target_h - total_h) // 2)
|
y = max(4, (target_h - total_h) // 2)
|
||||||
@@ -25,6 +27,6 @@ def placeholder_image(target_w: int, target_h: int, lines: list[str]) -> Image.I
|
|||||||
bbox = draw.textbbox((0, 0), line, font=font)
|
bbox = draw.textbbox((0, 0), line, font=font)
|
||||||
line_w = bbox[2] - bbox[0]
|
line_w = bbox[2] - bbox[0]
|
||||||
x = max(4, (target_w - line_w) // 2)
|
x = max(4, (target_w - line_w) // 2)
|
||||||
draw.text((x, y), line, fill=_FG, font=font)
|
draw_text(img, (x, y), line, font)
|
||||||
y += line_h
|
y += line_h
|
||||||
return img
|
return img
|
||||||
|
|||||||
@@ -17,10 +17,11 @@ from __future__ import annotations
|
|||||||
import io
|
import io
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..image_pipeline import DEFAULT_PALETTE_RGB, _quantize, draw_text, logical_render_size
|
from .. import panel_style
|
||||||
|
from ..image_pipeline import _quantize, draw_text, logical_render_size
|
||||||
from ..models import BatteryWidgetConfig, Frame, Widget
|
from ..models import BatteryWidgetConfig, Frame, Widget
|
||||||
from ..routers.common import battery_estimate_s
|
from ..routers.common import battery_estimate_s
|
||||||
from ._shared import placeholder_image
|
from ._shared import placeholder_image
|
||||||
@@ -28,45 +29,16 @@ from ._shared import placeholder_image
|
|||||||
ACTIONS: dict = {}
|
ACTIONS: dict = {}
|
||||||
ACTION_LABELS: dict[str, str] = {}
|
ACTION_LABELS: dict[str, str] = {}
|
||||||
|
|
||||||
BG = (255, 255, 255)
|
|
||||||
MUTED = (110, 110, 110)
|
|
||||||
|
|
||||||
# Same thresholds/colors as manage_overlay.py's own battery glyph (not
|
def _draw_icon(draw: ImageDraw.ImageDraw, cx: int, top: int, icon_w: int, icon_h: int, percent: int,
|
||||||
# shared code -- that one draws onto the manage-QR overlay in a fixed
|
palette_rgb: list | None = None) -> None:
|
||||||
# small size, this one fills an arbitrary widget region -- but the
|
"""Centers panel_style.draw_battery_icon (top-left-anchored) under
|
||||||
# "how worried should I be" color story should read the same wherever a
|
`cx` -- this widget's own layout picks a center point, that helper's
|
||||||
# battery glyph shows up on a panel). Exact panel ink RGB values, not
|
shared implementation (also used by manage_overlay.py's battery
|
||||||
# arbitrary reds/yellows/greens -- a flat fill already at a palette
|
readout) just needs a top-left corner."""
|
||||||
# color quantizes with zero dithering error once the whole composited
|
|
||||||
# canvas gets quantized, where an off-palette color would dither into a
|
|
||||||
# visible speckle at these small on-panel sizes.
|
|
||||||
_LOW = DEFAULT_PALETTE_RGB[3] # red
|
|
||||||
_MEDIUM = DEFAULT_PALETTE_RGB[2] # yellow
|
|
||||||
_HIGH = DEFAULT_PALETTE_RGB[5] # green
|
|
||||||
|
|
||||||
|
|
||||||
def _fill_color(percent: int) -> tuple[int, int, int]:
|
|
||||||
if percent <= 15:
|
|
||||||
return _LOW
|
|
||||||
if percent <= 40:
|
|
||||||
return _MEDIUM
|
|
||||||
return _HIGH
|
|
||||||
|
|
||||||
|
|
||||||
def _draw_icon(draw: ImageDraw.ImageDraw, cx: int, top: int, icon_w: int, icon_h: int, percent: int) -> None:
|
|
||||||
stroke = max(2, icon_h // 12)
|
|
||||||
nub_w = max(3, icon_w // 10)
|
nub_w = max(3, icon_w // 10)
|
||||||
nub_h = icon_h // 2
|
|
||||||
x0 = cx - (icon_w + nub_w) // 2
|
x0 = cx - (icon_w + nub_w) // 2
|
||||||
y0 = top
|
panel_style.draw_battery_icon(draw, x0, top, icon_w, icon_h, percent, palette_rgb)
|
||||||
inner_x0, inner_y0 = x0 + stroke, y0 + stroke
|
|
||||||
inner_x1, inner_y1 = x0 + icon_w - stroke, y0 + icon_h - stroke
|
|
||||||
fill_x1 = inner_x0 + round((inner_x1 - inner_x0) * (max(0, min(100, percent)) / 100))
|
|
||||||
if fill_x1 > inner_x0:
|
|
||||||
draw.rectangle([inner_x0, inner_y0, fill_x1, inner_y1], fill=_fill_color(percent))
|
|
||||||
draw.rectangle([x0, y0, x0 + icon_w, y0 + icon_h], outline=(0, 0, 0), width=stroke)
|
|
||||||
nub_y = y0 + (icon_h - nub_h) // 2
|
|
||||||
draw.rectangle([x0 + icon_w, nub_y, x0 + icon_w + nub_w, nub_y + nub_h], fill=(0, 0, 0))
|
|
||||||
|
|
||||||
|
|
||||||
def _format_estimate(seconds: float) -> str:
|
def _format_estimate(seconds: float) -> str:
|
||||||
@@ -99,22 +71,26 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i
|
|||||||
|
|
||||||
cfg = db.get(BatteryWidgetConfig, widget.id)
|
cfg = db.get(BatteryWidgetConfig, widget.id)
|
||||||
mode = cfg.mode if cfg else "detailed"
|
mode = cfg.mode if cfg else "detailed"
|
||||||
|
palette_rgb = frame.palette_rgb
|
||||||
|
|
||||||
img = Image.new("RGB", (target_w, target_h), BG)
|
img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h)
|
||||||
draw = ImageDraw.Draw(img)
|
cx = cx0 + cw // 2
|
||||||
cx = target_w // 2
|
|
||||||
|
|
||||||
icon_h = max(20, min(target_w, target_h) // 3)
|
icon_h = max(20, min(cw, ch) // 3)
|
||||||
icon_w = int(icon_h * 1.8)
|
icon_w = int(icon_h * 1.8)
|
||||||
icon_top = max(4, target_h // 8)
|
icon_top = max(4, cy0 + ch // 8)
|
||||||
_draw_icon(draw, cx, icon_top, icon_w, icon_h, percent)
|
_draw_icon(draw, cx, icon_top, icon_w, icon_h, percent, palette_rgb)
|
||||||
|
|
||||||
pct_font_size = max(18, min(target_w, target_h) // 3)
|
# The percent number picks up the icon's own charge-level color
|
||||||
pct_font = ImageFont.load_default(size=pct_font_size)
|
# (red/yellow/green) instead of plain black -- ties the two into one
|
||||||
|
# visual statement rather than "colored icon, black number".
|
||||||
|
pct_font_size = max(18, min(cw, ch) // 3)
|
||||||
|
pct_font = panel_style.font_bold(pct_font_size)
|
||||||
pct_text = f"{percent}%"
|
pct_text = f"{percent}%"
|
||||||
bbox = draw.textbbox((0, 0), pct_text, font=pct_font)
|
bbox = draw.textbbox((0, 0), pct_text, font=pct_font)
|
||||||
pct_y = icon_top + icon_h + 10
|
pct_y = icon_top + icon_h + 10
|
||||||
draw_text(img, (cx - (bbox[2] - bbox[0]) // 2, pct_y), pct_text, pct_font)
|
draw_text(img, (cx - (bbox[2] - bbox[0]) // 2, pct_y), pct_text, pct_font,
|
||||||
|
panel_style.battery_fill_color(percent, palette_rgb))
|
||||||
|
|
||||||
if mode == "detailed":
|
if mode == "detailed":
|
||||||
lines = []
|
lines = []
|
||||||
@@ -125,13 +101,13 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i
|
|||||||
lines.append(f"Reported {_format_age(frame.battery_as_of)}")
|
lines.append(f"Reported {_format_age(frame.battery_as_of)}")
|
||||||
|
|
||||||
small_font_size = max(11, pct_font_size // 3)
|
small_font_size = max(11, pct_font_size // 3)
|
||||||
small_font = ImageFont.load_default(size=small_font_size)
|
small_font = panel_style.font_regular(small_font_size)
|
||||||
y = pct_y + pct_font_size + 12
|
y = pct_y + pct_font_size + 12
|
||||||
for line in lines:
|
for line in lines:
|
||||||
if y + small_font_size > target_h - 4:
|
if y + small_font_size > cy0 + ch - 4:
|
||||||
break
|
break
|
||||||
lbbox = draw.textbbox((0, 0), line, font=small_font)
|
lbbox = draw.textbbox((0, 0), line, font=small_font)
|
||||||
draw_text(img, (cx - (lbbox[2] - lbbox[0]) // 2, y), line, small_font, MUTED)
|
draw_text(img, (cx - (lbbox[2] - lbbox[0]) // 2, y), line, small_font)
|
||||||
y += small_font_size + 6
|
y += small_font_size + 6
|
||||||
|
|
||||||
return img
|
return img
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
"""Weather widget: one of four display modes (see models.
|
"""Weather widget: one of four display modes (see models.
|
||||||
WeatherWidgetConfig) backed by a pluggable provider (app/weather/'s
|
WeatherWidgetConfig) backed by a pluggable provider (app/weather/'s
|
||||||
PROVIDERS registry -- Open-Meteo or NWS) and drawn by app/weather_render.
|
PROVIDERS registry -- Open-Meteo or NWS) and drawn by app/weather_render.
|
||||||
py's build() dispatch. No real "next"/"back" concept (same as
|
py's build() dispatch -- or, for "current"/"daily" modes with
|
||||||
whiteboard) -- a single "check now" action forces a re-fetch bypassing
|
render_style="modern", by app/html_render.py's Jinja2/headless-Chromium
|
||||||
the normal throttle."""
|
renderer instead (experimental; hourly/multi_city always render classic
|
||||||
|
regardless of render_style, see html_render's module docstring). No real
|
||||||
|
"next"/"back" concept (same as whiteboard) -- a single "check now"
|
||||||
|
action forces a re-fetch bypassing the normal throttle."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -27,6 +30,18 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i
|
|||||||
data = get_or_refresh_weather_widget_data(db, frame, widget)
|
data = get_or_refresh_weather_widget_data(db, frame, widget)
|
||||||
if data is None:
|
if data is None:
|
||||||
return placeholder_image(target_w, target_h, ["Weather widget", "not configured yet"])
|
return placeholder_image(target_w, target_h, ["Weather widget", "not configured yet"])
|
||||||
|
|
||||||
|
if cfg.render_style == "modern" and cfg.mode in ("current", "daily"):
|
||||||
|
# Local import: html_render pulls in Playwright, a real headless-
|
||||||
|
# Chromium dependency -- every other widget type, and this one's
|
||||||
|
# own classic/hourly/multi_city paths, should never pay for it
|
||||||
|
# (same reasoning as image_pipeline.render_placeholder's local
|
||||||
|
# `import qrcode`).
|
||||||
|
from .. import html_render
|
||||||
|
|
||||||
|
return html_render.build(cfg.mode, data, target_w, target_h, frame.palette_rgb, cfg.units,
|
||||||
|
city_label=cfg.city_label or "")
|
||||||
|
|
||||||
return weather_render.build(
|
return weather_render.build(
|
||||||
cfg.mode, data, target_w, target_h, frame.palette_rgb, cfg.units,
|
cfg.mode, data, target_w, target_h, frame.palette_rgb, cfg.units,
|
||||||
city_label=cfg.city_label or "", interval_hours=cfg.hourly_interval_hours,
|
city_label=cfg.city_label or "", interval_hours=cfg.hourly_interval_hours,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ starlette==0.41.3
|
|||||||
uvicorn[standard]==0.34.0
|
uvicorn[standard]==0.34.0
|
||||||
httpx==0.28.1
|
httpx==0.28.1
|
||||||
pillow==12.3.0
|
pillow==12.3.0
|
||||||
numpy==2.5.1
|
|
||||||
python-multipart==0.0.20
|
python-multipart==0.0.20
|
||||||
jinja2==3.1.5
|
jinja2==3.1.5
|
||||||
sqlalchemy==2.0.51
|
sqlalchemy==2.0.51
|
||||||
@@ -12,3 +11,5 @@ icalendar==7.2.2
|
|||||||
recurring-ical-events==3.8.2
|
recurring-ical-events==3.8.2
|
||||||
caldav==3.2.1
|
caldav==3.2.1
|
||||||
pypdfium2==5.12.1
|
pypdfium2==5.12.1
|
||||||
|
playwright==1.61.0
|
||||||
|
numpy==2.5.1
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ def test_expected_columns_exist_on_current_schema():
|
|||||||
assert "ix_frame_button_actions_widget_button" in button_action_indexes # migration 28
|
assert "ix_frame_button_actions_widget_button" in button_action_indexes # migration 28
|
||||||
assert {"hold_duration_ms", "next_hold_action", "back_hold_action", "last_cycled_layout_id"} <= frame_columns
|
assert {"hold_duration_ms", "next_hold_action", "back_hold_action", "last_cycled_layout_id"} <= frame_columns
|
||||||
assert {"last_displayed_image", "last_displayed_at"} <= frame_columns # migration 30
|
assert {"last_displayed_image", "last_displayed_at"} <= frame_columns # migration 30
|
||||||
|
assert "render_style" in weather_widget_columns # migration 31
|
||||||
|
|
||||||
|
|
||||||
# --- widget system backfill (migration 16 + _ensure_widgets_backfilled) ---
|
# --- widget system backfill (migration 16 + _ensure_widgets_backfilled) ---
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from app.models import (
|
|||||||
TaskWidgetConfig,
|
TaskWidgetConfig,
|
||||||
User,
|
User,
|
||||||
Widget,
|
Widget,
|
||||||
|
WeatherWidgetConfig,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .conftest import csrf_headers, link_user, login, make_user
|
from .conftest import csrf_headers, link_user, login, make_user
|
||||||
@@ -56,6 +57,16 @@ def _add_tasks_widget(db_session, frame_id=1, x=3, y=0, w=2, h=2, sort_order=2)
|
|||||||
return widget
|
return widget
|
||||||
|
|
||||||
|
|
||||||
|
def _add_weather_widget(db_session, frame_id=1, x=0, y=0, w=2, h=2, sort_order=1) -> Widget:
|
||||||
|
widget = Widget(frame_id=frame_id, widget_type="weather", x=x, y=y, w=w, h=h,
|
||||||
|
sort_order=sort_order, created_at=time.time())
|
||||||
|
db_session.add(widget)
|
||||||
|
db_session.flush()
|
||||||
|
db_session.add(WeatherWidgetConfig(widget_id=widget.id))
|
||||||
|
db_session.commit()
|
||||||
|
return widget
|
||||||
|
|
||||||
|
|
||||||
def _setup_alice(client) -> None:
|
def _setup_alice(client) -> None:
|
||||||
resp = client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
resp = client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||||
assert resp.status_code == 303, resp.text
|
assert resp.status_code == 303, resp.text
|
||||||
@@ -89,6 +100,48 @@ def test_save_captures_placement_and_photo_settings_but_not_queue_state(client,
|
|||||||
"queue_target_len": 30}
|
"queue_target_len": 30}
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_and_apply_round_trip_weather_settings(client, db_session):
|
||||||
|
_setup_alice(client)
|
||||||
|
weather = _add_weather_widget(db_session)
|
||||||
|
with db_session.no_autoflush:
|
||||||
|
wcfg = db_session.get(WeatherWidgetConfig, weather.id)
|
||||||
|
wcfg.mode = "daily"
|
||||||
|
wcfg.provider = "nws"
|
||||||
|
wcfg.units = "celsius"
|
||||||
|
wcfg.city_label = "Boston, MA"
|
||||||
|
wcfg.city_latitude = 42.36
|
||||||
|
wcfg.city_longitude = -71.06
|
||||||
|
wcfg.hourly_interval_hours = 6
|
||||||
|
wcfg.daily_days = 7
|
||||||
|
wcfg.checked_at = 12345.0
|
||||||
|
wcfg.cached = {"stale": "runtime state, not a setting"}
|
||||||
|
db_session.commit()
|
||||||
|
db_session.expunge(wcfg)
|
||||||
|
|
||||||
|
save_resp = client.post("/api/frames/1/layouts", json={"name": "Weather Layout"}, headers=csrf_headers(client))
|
||||||
|
assert save_resp.status_code == 200, save_resp.text
|
||||||
|
layout = db_session.query(SavedLayout).filter_by(user_id=1, name="Weather Layout").one()
|
||||||
|
snap = db_session.query(SavedLayoutWidget).filter_by(saved_layout_id=layout.id, widget_type="weather").one()
|
||||||
|
assert snap.config == {
|
||||||
|
"mode": "daily", "provider": "nws", "units": "celsius", "city_label": "Boston, MA",
|
||||||
|
"city_latitude": 42.36, "city_longitude": -71.06, "hourly_interval_hours": 6, "daily_days": 7,
|
||||||
|
"cities": None, "render_style": "classic",
|
||||||
|
}
|
||||||
|
|
||||||
|
client.delete("/api/frames/1/widgets", headers=csrf_headers(client))
|
||||||
|
apply_resp = client.post(f"/api/frames/1/layouts/{layout.id}/apply", headers=csrf_headers(client))
|
||||||
|
assert apply_resp.status_code == 200, apply_resp.text
|
||||||
|
|
||||||
|
new_widget = db_session.query(Widget).filter_by(frame_id=1, widget_type="weather").one()
|
||||||
|
new_cfg = db_session.get(WeatherWidgetConfig, new_widget.id)
|
||||||
|
assert (new_cfg.mode, new_cfg.provider, new_cfg.units) == ("daily", "nws", "celsius")
|
||||||
|
assert (new_cfg.city_label, new_cfg.city_latitude, new_cfg.city_longitude) == ("Boston, MA", 42.36, -71.06)
|
||||||
|
assert (new_cfg.hourly_interval_hours, new_cfg.daily_days) == (6, 7)
|
||||||
|
# Runtime fetch-cache state is never captured/restored by a saved layout.
|
||||||
|
assert new_cfg.checked_at == 0.0
|
||||||
|
assert new_cfg.cached is None
|
||||||
|
|
||||||
|
|
||||||
def test_save_captures_calendar_sources_and_button_actions(client, db_session):
|
def test_save_captures_calendar_sources_and_button_actions(client, db_session):
|
||||||
_setup_alice(client)
|
_setup_alice(client)
|
||||||
db_session.query(Widget).filter_by(frame_id=1, widget_type="photos").delete()
|
db_session.query(Widget).filter_by(frame_id=1, widget_type="photos").delete()
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ def test_solid_border_draws_the_configured_palette_color(client, db_session):
|
|||||||
assert resp.status_code == 200, resp.text
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
img = _preview_pixels(client)
|
img = _preview_pixels(client)
|
||||||
assert img.getpixel((0, 0)) == (98, 32, 30) # DEFAULT_PALETTE_RGB[3], top-left corner of the stroke
|
assert img.getpixel((0, 0)) == (207, 0, 15) # DEFAULT_PALETTE_RGB[3], top-left corner of the stroke
|
||||||
|
|
||||||
|
|
||||||
def test_no_border_leaves_the_edge_unmarked(client, db_session):
|
def test_no_border_leaves_the_edge_unmarked(client, db_session):
|
||||||
@@ -146,4 +146,4 @@ def test_no_border_leaves_the_edge_unmarked(client, db_session):
|
|||||||
render path already painting it that color."""
|
render path already painting it that color."""
|
||||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||||
img = _preview_pixels(client)
|
img = _preview_pixels(client)
|
||||||
assert img.getpixel((0, 0)) != (98, 32, 30)
|
assert img.getpixel((0, 0)) != (207, 0, 15)
|
||||||
|
|||||||
@@ -243,6 +243,35 @@ def test_config_save_switching_mode_clears_the_now_incompatible_cache(client, db
|
|||||||
assert resp.status_code == 200, resp.text
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_preview_weather_honors_modern_render_style(client, db_session, monkeypatch):
|
||||||
|
"""Regression test: api_widget_preview_weather originally called
|
||||||
|
weather_render.render_weather_preview_png directly, unconditionally --
|
||||||
|
the dialog's own live preview never reflected render_style="modern" at
|
||||||
|
all, even though the real device-facing render (widgets/weather.py's
|
||||||
|
render()) did. Route through html_render instead for modern/current or
|
||||||
|
modern/daily, same as the device path -- assert it's actually reached,
|
||||||
|
not just that the request 200s (it would 200 either way if this
|
||||||
|
silently fell back to classic)."""
|
||||||
|
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||||
|
widget = _add_weather_widget(db_session, mode="current", render_style="modern")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.routers.api_widgets.get_or_refresh_weather_widget_data",
|
||||||
|
lambda db, frame, widget, force=False: {"temp": 72.0, "category": "clear"},
|
||||||
|
)
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def _fake_render_html_to_image(html, target_w, target_h):
|
||||||
|
calls.append((target_w, target_h))
|
||||||
|
return Image.new("RGB", (target_w, target_h), (255, 255, 255))
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.html_render.render_html_to_image", _fake_render_html_to_image)
|
||||||
|
|
||||||
|
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/weather")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.headers["content-type"] == "image/png"
|
||||||
|
assert calls, "html_render.render_html_to_image was never called -- preview endpoint didn't honor render_style"
|
||||||
|
|
||||||
|
|
||||||
def test_config_save_truncates_an_overlong_tasks_name(client, db_session):
|
def test_config_save_truncates_an_overlong_tasks_name(client, db_session):
|
||||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||||
widget = _add_tasks_widget(db_session)
|
widget = _add_tasks_widget(db_session)
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from app import grid, widgets
|
from PIL import Image
|
||||||
|
|
||||||
|
from app import grid, html_render, widgets
|
||||||
from app.models import Frame, WeatherWidgetConfig, Widget
|
from app.models import Frame, WeatherWidgetConfig, Widget
|
||||||
|
|
||||||
|
|
||||||
@@ -109,3 +111,79 @@ def test_check_now_forces_a_refetch(db_session, monkeypatch):
|
|||||||
def test_action_labels():
|
def test_action_labels():
|
||||||
assert set(widgets.weather.ACTIONS.keys()) == {"check_now"}
|
assert set(widgets.weather.ACTIONS.keys()) == {"check_now"}
|
||||||
assert widgets.weather.ACTION_LABELS == {"check_now": "Check for updates"}
|
assert widgets.weather.ACTION_LABELS == {"check_now": "Check for updates"}
|
||||||
|
|
||||||
|
|
||||||
|
# --- render_style="modern" (app/html_render.py) -------------------------
|
||||||
|
# No real browser here -- html_render.render_html_to_image is monkeypatched
|
||||||
|
# to a stub, so these tests exercise weather.py's dispatch + html_render's
|
||||||
|
# own template-rendering/ordered_dither logic, not Playwright/Chromium
|
||||||
|
# itself (that needs a real browser install -- see the run-server-driven
|
||||||
|
# manual verification these tests don't replace).
|
||||||
|
|
||||||
|
def _stub_render_html_to_image(monkeypatch, fill=(10, 20, 200)):
|
||||||
|
def _stub(html, target_w, target_h):
|
||||||
|
return Image.new("RGB", (target_w, target_h), fill)
|
||||||
|
|
||||||
|
monkeypatch.setattr(html_render, "render_html_to_image", _stub)
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_modern_current_mode(db_session, monkeypatch):
|
||||||
|
frame, widget = _make_widget(db_session, mode="current", city_label="Portland", render_style="modern")
|
||||||
|
monkeypatch.setattr(widgets.weather, "get_or_refresh_weather_widget_data",
|
||||||
|
lambda db, frame, widget: {"temp": 72.0, "category": "clear"})
|
||||||
|
_stub_render_html_to_image(monkeypatch)
|
||||||
|
|
||||||
|
img = widgets.weather.render(db_session, frame, widget, 300, 200)
|
||||||
|
assert img.size == (300, 200)
|
||||||
|
assert img.mode == "RGB"
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_modern_daily_mode(db_session, monkeypatch):
|
||||||
|
frame, widget = _make_widget(db_session, mode="daily", city_label="Denver", daily_days=5, render_style="modern")
|
||||||
|
daily = {f"2026-07-{27 + i}": {"high": 70 + i, "low": 50 + i, "category": "clear"} for i in range(5)}
|
||||||
|
monkeypatch.setattr(widgets.weather, "get_or_refresh_weather_widget_data", lambda db, frame, widget: daily)
|
||||||
|
_stub_render_html_to_image(monkeypatch)
|
||||||
|
|
||||||
|
img = widgets.weather.render(db_session, frame, widget, 400, 300)
|
||||||
|
assert img.size == (400, 300)
|
||||||
|
assert img.mode == "RGB"
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_modern_ordered_dither_is_exact_palette(db_session, monkeypatch):
|
||||||
|
"""The whole point of doing ordered dithering inside html_render (see
|
||||||
|
its module docstring) is that its output is already exact palette
|
||||||
|
colors before the shared whole-canvas Floyd-Steinberg pass ever sees
|
||||||
|
it -- assert that directly, not just "an image came back"."""
|
||||||
|
frame, widget = _make_widget(db_session, mode="current", render_style="modern")
|
||||||
|
monkeypatch.setattr(widgets.weather, "get_or_refresh_weather_widget_data",
|
||||||
|
lambda db, frame, widget: {"temp": 72.0, "category": "clear"})
|
||||||
|
# A mid-gray fill is nowhere near any of DEFAULT_PALETTE_RGB's 6 exact
|
||||||
|
# colors -- if ordered_dither's nearest-palette-match ran, every pixel
|
||||||
|
# must land on one of them regardless.
|
||||||
|
_stub_render_html_to_image(monkeypatch, fill=(128, 128, 128))
|
||||||
|
|
||||||
|
img = widgets.weather.render(db_session, frame, widget, 120, 100)
|
||||||
|
from app.image_pipeline import DEFAULT_PALETTE_RGB
|
||||||
|
|
||||||
|
palette = set(DEFAULT_PALETTE_RGB)
|
||||||
|
assert set(img.getdata()) <= palette
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_modern_falls_back_to_classic_for_hourly_and_multi_city(db_session, monkeypatch):
|
||||||
|
"""hourly/multi_city have no "modern" template yet (see html_render's
|
||||||
|
module docstring) -- render_style="modern" on those modes must still
|
||||||
|
produce the classic PIL render, not error or silently do nothing.
|
||||||
|
Deliberately does NOT stub html_render, so this also proves the
|
||||||
|
classic path never imports it for these modes."""
|
||||||
|
frame, widget = _make_widget(db_session, mode="multi_city", render_style="modern")
|
||||||
|
cities = [{"label": "Portland, Oregon, United States", "high": 75, "low": 55, "category": "clear"}]
|
||||||
|
monkeypatch.setattr(widgets.weather, "get_or_refresh_weather_widget_data", lambda db, frame, widget: cities)
|
||||||
|
|
||||||
|
img = widgets.weather.render(db_session, frame, widget, 400, 300)
|
||||||
|
assert img.size == (400, 300)
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_style_default_is_classic(db_session, monkeypatch):
|
||||||
|
frame, widget = _make_widget(db_session, mode="current")
|
||||||
|
cfg = db_session.get(WeatherWidgetConfig, widget.id)
|
||||||
|
assert cfg.render_style == "classic"
|
||||||
|
|||||||
Reference in New Issue
Block a user