Compare commits
21
Commits
v1.4.1
..
466efdb873
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
466efdb873 | ||
|
|
e363db0e4e | ||
|
|
5f4f8f2ea7 | ||
|
|
e331f5e5a1 | ||
|
|
c6dad191fb | ||
|
|
8ea1c53ec3 | ||
|
|
d34eb1bf45 | ||
|
|
bcea090e73 | ||
|
|
37d57a1f88 | ||
|
|
d974e872ba | ||
|
|
dfe9d71971 | ||
|
|
05b417a29b | ||
|
|
a48c84ed4a | ||
|
|
d1f1968317 | ||
|
|
83994aab7b | ||
|
|
5866c2f040 | ||
|
|
dd038f8e46 | ||
|
|
3fdda096a9 | ||
|
|
575b3cfa61 | ||
|
|
aa4a382c1b | ||
|
|
684225422c |
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ mkdir -p "$SCRATCH"
|
|||||||
|
|
||||||
DATABASE_URL="sqlite:///$SCRATCH/test.db" \
|
DATABASE_URL="sqlite:///$SCRATCH/test.db" \
|
||||||
CONFIG_PATH="$SCRATCH/config.json" \
|
CONFIG_PATH="$SCRATCH/config.json" \
|
||||||
|
LOG_PATH="$SCRATCH/app.log" \
|
||||||
.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port "$PORT" \
|
.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port "$PORT" \
|
||||||
> "$SCRATCH/server.log" 2>&1 &
|
> "$SCRATCH/server.log" 2>&1 &
|
||||||
PID=$!
|
PID=$!
|
||||||
|
|||||||
@@ -69,4 +69,4 @@ jobs:
|
|||||||
ssh-keyscan -p "$DEPLOY_PORT" "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
ssh-keyscan -p "$DEPLOY_PORT" "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
ssh -i ~/.ssh/deploy_key -p "$DEPLOY_PORT" -o StrictHostKeyChecking=yes \
|
ssh -i ~/.ssh/deploy_key -p "$DEPLOY_PORT" -o StrictHostKeyChecking=yes \
|
||||||
espressoframe_deployer@"$DEPLOY_HOST" \
|
espressoframe_deployer@"$DEPLOY_HOST" \
|
||||||
'cd ~/espresso-frame && docker compose pull && docker compose up -d'
|
'cd ~/espresso-frame && docker compose down && docker compose pull && docker compose up -d'
|
||||||
|
|||||||
+144
@@ -148,6 +148,145 @@ grid footprint, rather than continuously scaling constants tuned for a
|
|||||||
full ~800x480 canvas -- falls back to agenda view if a widget is too small
|
full ~800x480 canvas -- falls back to agenda view if a widget is too small
|
||||||
for month view to stay legible.
|
for month view to stay legible.
|
||||||
|
|
||||||
|
### "Modern" render style (experimental)
|
||||||
|
|
||||||
|
Every widget type except photos has a `render_style` column (`"classic"`
|
||||||
|
default | `"modern"`) that swaps its hand-drawn PIL primitives for an
|
||||||
|
HTML/CSS render: a Jinja2 template (`app/templates/widget_html/`) drawn
|
||||||
|
through a persistent headless-Chromium browser (`app/html_render.py`,
|
||||||
|
Playwright) instead of `ImageDraw` -- gradients, shadows, and soft icon
|
||||||
|
shading PIL can't easily do. Calendar's own modern-style builders (all
|
||||||
|
four view modes) live in `app/calendar_html_render.py` rather than
|
||||||
|
`html_render.py` itself, mirroring `calendar_render.py`'s own separation
|
||||||
|
from the simpler widget types.
|
||||||
|
|
||||||
|
Every modern-style builder runs its own `ordered_dither` (Bayer/ordered,
|
||||||
|
not Floyd-Steinberg) before returning, committing the widget to exact
|
||||||
|
palette colors *before* compositing -- safe to mix with photo/other
|
||||||
|
classic-rendered widgets on the same frame without a Floyd-Steinberg
|
||||||
|
seam at the boundary, because ordered dithering has no cross-pixel error
|
||||||
|
term the way Floyd-Steinberg's diffusion does (see `html_render.py`'s
|
||||||
|
module docstring). No `Frame`-level dithering setting was needed to make
|
||||||
|
this work.
|
||||||
|
|
||||||
|
Not offered for the **photos** widget -- a real photograph isn't a
|
||||||
|
synthesized dashboard card, and photos has a different concern instead:
|
||||||
|
its own independent palette/dithering strength (`Frame.photo_palette_rgb`
|
||||||
|
/ `photo_dither_strength`, a second "Photos configuration" card in
|
||||||
|
Advanced Configuration, separate from the main `palette_rgb`/
|
||||||
|
`dither_strength` every other widget uses). `widgets/photos.py`'s
|
||||||
|
`render()` quantizes itself against these before returning, so a frame
|
||||||
|
can tune the rest of its widgets' look (e.g. a calibrated palette for
|
||||||
|
modern-style dashboard widgets) independently of what actually looks
|
||||||
|
best for real photographs, with no `render_panel` changes needed --
|
||||||
|
see that module's own docstring for the one small, accepted edge case
|
||||||
|
(a border on a photos widget whose palette genuinely diverges from the
|
||||||
|
frame's main one).
|
||||||
|
|
||||||
|
Playwright/Chromium is a real, heavyweight runtime dependency imported
|
||||||
|
lazily only when a widget actually uses modern style. Its browser binary
|
||||||
|
is fetched by `start.sh` at container startup rather than baked into the
|
||||||
|
image (see `server/Dockerfile`'s own comment) -- a single ~181MB
|
||||||
|
`chrome-headless-shell` binary can't be split across Docker layers the
|
||||||
|
way this project's pip/npm installs were, and confirmed-failed to push
|
||||||
|
to the registry as a build-time layer; cached on the `/data` volume
|
||||||
|
(`PLAYWRIGHT_BROWSERS_PATH`) so only the very first boot on a fresh
|
||||||
|
volume actually downloads it. Still real-panel-unverified -- treat every
|
||||||
|
"modern" style as experimental regardless of deploy status.
|
||||||
|
|
||||||
|
Per-widget-type notes:
|
||||||
|
|
||||||
|
- **weather**: `current`/`daily` modes only -- `hourly`/`multi_city`
|
||||||
|
always render classic regardless of this setting (see the Weather
|
||||||
|
widget section below).
|
||||||
|
- **calendar**: all four view modes (agenda/today_tomorrow/week/month)
|
||||||
|
have a modern builder -- the only widget type with full modern-style
|
||||||
|
coverage from the start, rather than a partial rollout like weather's.
|
||||||
|
Month view's "falls back to agenda below a size threshold" behavior
|
||||||
|
(`_month_view_fits`) is honored identically in both styles.
|
||||||
|
- **battery/text/tasks**: full coverage (both battery modes; text reuses
|
||||||
|
its own `_fit()` shrink-to-fit sizing logic, only the drawing differs).
|
||||||
|
- **static image/whiteboard**: modern style is the *first* visual chrome
|
||||||
|
either widget type has ever had (classic draws the image with zero
|
||||||
|
frame/card at all) -- a rounded-corner, shadowed card
|
||||||
|
(`framed_image.html.jinja`, shared between the two) wrapping the
|
||||||
|
already-composed image.
|
||||||
|
|
||||||
|
### Themes for modern-style widgets
|
||||||
|
|
||||||
|
`Frame.theme` (String, default `"classic"`, one Advanced Configuration
|
||||||
|
`<select>`) picks a curated visual preset for every modern-style widget
|
||||||
|
on that frame -- font family, corner radius, drop shadow, and an accent
|
||||||
|
hue for widgets with a header/accent region. Presets live in
|
||||||
|
`app/theme_tokens.py`'s `THEMES` dict; `resolve_theme(theme_name,
|
||||||
|
widget_kind, palette_rgb)` turns one into concrete, ready-to-render
|
||||||
|
values (`accent_hex`/`accent_hex_dark`, resolved `font_regular`/
|
||||||
|
`font_bold` file paths, `radius`, `shadow`, `accent_amplitude`). Inspired
|
||||||
|
by [Tesserae](https://github.com/dmellok/tesserae)'s (AGPL-3.0) own
|
||||||
|
three-layer CSS custom-property theme system -- this is an original
|
||||||
|
reimplementation of that *architecture*, not a copy of its token file
|
||||||
|
(see this repo's `CLAUDE.md` on copyleft dependencies).
|
||||||
|
|
||||||
|
**What a theme actually changes, in practice**: the header/accent color
|
||||||
|
is the most visible change, but `font_family` applies to *every* text
|
||||||
|
element in the widget, not just the header title -- day labels,
|
||||||
|
temperatures, task rows, event times, day numbers all switch fonts too
|
||||||
|
(e.g. "Moss" is serif, "Ochre" a slab serif), often more noticeable than
|
||||||
|
the accent color on text-heavy widgets. `radius`/`shadow` change the
|
||||||
|
card's corners and whether it has a drop shadow at all (e.g. "Slate" is
|
||||||
|
square-cornered with no shadow). Battery and text/static/whiteboard have
|
||||||
|
no header, so for those a theme only shows up as font/radius/shadow
|
||||||
|
differences, not an accent color.
|
||||||
|
|
||||||
|
**A theme is purely stylistic, never functional color-coding.** Battery's
|
||||||
|
charge-level red/yellow/green, calendar/tasks' per-owner event color
|
||||||
|
chips, and text's user-authored inline run colors are status/identity
|
||||||
|
signals, not style choices -- no theme may recolor them, and every
|
||||||
|
`build_*`/`resolve_theme` call site that touches those stays on its own
|
||||||
|
existing logic untouched. Text's own per-widget `font_family` setting
|
||||||
|
(a user's explicit content-level choice, same carve-out reasoning) is
|
||||||
|
similarly never overridden by a theme -- `build_text` accepts a
|
||||||
|
`theme_name` param for signature uniformity with every other modern-
|
||||||
|
style builder but deliberately ignores it.
|
||||||
|
|
||||||
|
**Rich accent hues, not just the 6 exact panel inks.** A theme's
|
||||||
|
`accent_hex` can be any arbitrary color (e.g. terracotta, moss, slate) --
|
||||||
|
`html_render.ordered_dither_regions(rendered, palette_rgb,
|
||||||
|
base_amplitude, accent_regions=[(rect, amplitude), ...])` dithers the
|
||||||
|
whole widget at the existing safe default (`ordered_dither`'s tuned 48,
|
||||||
|
unchanged, still icon/text-legible) and then *separately* re-dithers
|
||||||
|
just the accent rectangle (a header bar's already-computed pixel rect)
|
||||||
|
at a theme's higher `accent_amplitude` (~130) and pastes it back. Safe
|
||||||
|
to do per-region for the same reason `ordered_dither` itself is safe
|
||||||
|
per-widget: ordered (Bayer) dithering has no cross-pixel error term, so
|
||||||
|
a region's result depends only on its own pixels. A single higher
|
||||||
|
amplitude applied to the *whole* widget instead was tried and rejected --
|
||||||
|
it washes out pale content (a weather icon's white cloud body nearly
|
||||||
|
vanished in testing); confining the higher amplitude to just the accent
|
||||||
|
rect avoids that while still letting the rect approximate a rich hue via
|
||||||
|
denser stippling instead of flatly snapping to one nearest ink (what
|
||||||
|
happens to a rich hue at the base amplitude).
|
||||||
|
|
||||||
|
**"classic" is a deliberately no-visual-change default.** Its
|
||||||
|
`accent_hex` is `None`, meaning "keep this widget kind's own pre-theme
|
||||||
|
look exactly": weather's header was always a fixed blue gradient (now
|
||||||
|
`theme_tokens._CLASSIC_WEATHER_GRADIENT`, byte-identical to the old
|
||||||
|
module-level `ACCENT_START`/`ACCENT_END` constants this system
|
||||||
|
replaced); tasks/calendar's header was always a flat single ink resolved
|
||||||
|
through `panel_style.THEME` (still is, just via `resolve_theme` now).
|
||||||
|
Widget kinds with no ink of their own (battery/text/static/whiteboard)
|
||||||
|
fall back to black, though none of their templates currently have an
|
||||||
|
accent-colored surface for it to visibly affect.
|
||||||
|
|
||||||
|
Which widgets get the richer accent-region treatment: weather's
|
||||||
|
`build_daily` (the header bar, when `city_label` is set), tasks, and
|
||||||
|
calendar's four view builders (each already computed a `header_h` in
|
||||||
|
Python for layout, reused as the accent rect). Weather's `build_current`,
|
||||||
|
battery, and static/whiteboard's shared `build_framed_image` are
|
||||||
|
theme-aware for font/radius/shadow only -- no header/accent region to
|
||||||
|
dither richer, so they call plain `ordered_dither` exactly as before
|
||||||
|
themes existed.
|
||||||
|
|
||||||
## Button actions
|
## Button actions
|
||||||
|
|
||||||
Each physical button (NEXT/BACK) runs the `(widget, action)` binding of
|
Each physical button (NEXT/BACK) runs the `(widget, action)` binding of
|
||||||
@@ -269,6 +408,11 @@ 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) -- see "Modern render style" above; weather's
|
||||||
|
own modern coverage is `current`/`daily` only, `hourly`/`multi_city`
|
||||||
|
always render classic regardless of this setting.
|
||||||
|
|
||||||
`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,61 @@ 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. 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
|
||||||
|
|
||||||
|
# The headless Chromium binary itself is deliberately NOT installed
|
||||||
|
# here at build time. `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, which confirmed-failed
|
||||||
|
# to push to this project's registry (the same Cloudflare single-blob/
|
||||||
|
# layer limit that forced the pip/npm splits elsewhere in this file --
|
||||||
|
# see their comments). Fix: start.sh downloads it at container startup
|
||||||
|
# instead, cached on the /data volume (PLAYWRIGHT_BROWSERS_PATH below)
|
||||||
|
# so it survives restarts/redeploys and only ever downloads once per
|
||||||
|
# volume, not once per image layer. Trade-off: first boot on a fresh
|
||||||
|
# volume needs network access to Playwright's CDN -- true of Immich/
|
||||||
|
# weather API access too, so not a new requirement for this server.
|
||||||
|
ENV PLAYWRIGHT_BROWSERS_PATH=/data/.playwright-browsers
|
||||||
|
|
||||||
# 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
|
||||||
|
|||||||
+9
-1
@@ -91,11 +91,19 @@ algorithm itself -- it just streams the response straight to the panel.
|
|||||||
again until a recharge is detected and it crosses again. No SMTP
|
again until a recharge is detected and it crosses again. No SMTP
|
||||||
configured, or no email on the relevant account, and both features
|
configured, or no email on the relevant account, and both features
|
||||||
silently no-op rather than erroring.
|
silently no-op rather than erroring.
|
||||||
|
- **Server logs.** `/admin/logs` shows the tail of the process's own
|
||||||
|
log file (`LOG_PATH` env var, default `/data/server.log` -- the same
|
||||||
|
`/data` volume as the database and legacy config, so it survives
|
||||||
|
container restarts/redeploys; `LOG_LEVEL` env var, default `INFO`).
|
||||||
|
Rotates at ~2MB x 3 backups; the page only reads the current file,
|
||||||
|
"Download full log" streams it raw. There's no log shipping/
|
||||||
|
aggregation beyond this -- it's a single-container deployment, so
|
||||||
|
the file *is* the log.
|
||||||
|
|
||||||
## Endpoints
|
## Endpoints
|
||||||
|
|
||||||
Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
|
Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
|
||||||
`/admin`, `/frames/{id}` (Photos), `/frames/{id}/config`,
|
`/admin`, `/admin/logs`, `/frames/{id}` (Photos), `/frames/{id}/config`,
|
||||||
`/frames/{id}/stats`, `/m/{manage_token}`.
|
`/frames/{id}/stats`, `/m/{manage_token}`.
|
||||||
|
|
||||||
### Device protocol (`/frame/*` -- paths frozen; auth = `?id=` + `?token=`)
|
### Device protocol (`/frame/*` -- paths frozen; auth = `?id=` + `?token=`)
|
||||||
|
|||||||
@@ -0,0 +1,327 @@
|
|||||||
|
"""Calendar widget's "modern" render style -- all four view modes
|
||||||
|
(agenda/today_tomorrow/week/month), mirroring calendar_render.py's own
|
||||||
|
_build dispatch shape exactly so app/widgets/calendar.py and the
|
||||||
|
calendar preview endpoint can call either module identically. Kept in
|
||||||
|
its own module rather than joining app/html_render.py's other build_*
|
||||||
|
functions, mirroring calendar_render.py's own separation from the
|
||||||
|
simpler widgets (calendar is the one case where html_render.py growing
|
||||||
|
a 5th unrelated builder starts to hurt readability).
|
||||||
|
|
||||||
|
Reuses calendar_render's own private helpers (_events_on_day/_event_
|
||||||
|
colors/_event_start/_fmt_time/_weather_for_day/_month_view_fits/
|
||||||
|
_add_months) so a modern-style view's event list/colors/times/weather/
|
||||||
|
month-grid math match the classic renderer's data exactly -- only the
|
||||||
|
drawing differs, same relationship weather's build_current/build_daily
|
||||||
|
have with weather_render.py."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import calendar as calendar_module
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from . import html_render, panel_style, theme_tokens
|
||||||
|
from .calendar_render import (
|
||||||
|
MARGIN,
|
||||||
|
WEEKDAY_NAMES,
|
||||||
|
_add_months,
|
||||||
|
_event_colors,
|
||||||
|
_event_start,
|
||||||
|
_events_on_day,
|
||||||
|
_fmt_time,
|
||||||
|
_month_view_fits,
|
||||||
|
_weather_for_day,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _weather_row(weather_cities, day, units) -> list[dict]:
|
||||||
|
entries = _weather_for_day(weather_cities, day)
|
||||||
|
return [
|
||||||
|
{"emoji": html_render.CATEGORY_EMOJI.get(e["category"], ""), "high": round(e["high"]), "low": round(e["low"])}
|
||||||
|
for e in entries
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _day_section_data(day: date, events: list[dict], tz: ZoneInfo, palette_rgb, weather_cities,
|
||||||
|
weather_units: str, owners_seen: list[str], rows_avail_h: int, row_h: int) -> dict:
|
||||||
|
"""One day's {header, weather_entries, rows, more_count} -- shared by
|
||||||
|
build_agenda/build_today_tomorrow/build_week's vertical layout, same
|
||||||
|
reuse relationship calendar_render._draw_agenda_day has with
|
||||||
|
_build_agenda/_build_today_tomorrow. `rows_avail_h` is the *rows*
|
||||||
|
area's own pixel budget only -- the caller has already reserved a
|
||||||
|
separate, uniform header_h (which is where weather actually renders,
|
||||||
|
see the day-header macro) for every section, so this function
|
||||||
|
doesn't need to account for weather space itself."""
|
||||||
|
header = day.strftime("%A, %B ") + str(day.day)
|
||||||
|
weather_entries = _weather_row(weather_cities, day, weather_units)
|
||||||
|
max_rows = max(0, rows_avail_h // row_h)
|
||||||
|
|
||||||
|
day_events = _events_on_day(events, day, tz)
|
||||||
|
rows = []
|
||||||
|
for event in day_events[:max_rows]:
|
||||||
|
colors = _event_colors(event, owners_seen, palette_rgb)
|
||||||
|
time_str = "All day" if event["all_day"] else _fmt_time(_event_start(event, tz))
|
||||||
|
rows.append({"colors": [html_render._rgb_to_hex(c) for c in colors], "time": time_str,
|
||||||
|
"summary": event["summary"]})
|
||||||
|
return {"header": header, "weather_entries": weather_entries, "rows": rows,
|
||||||
|
"more_count": max(0, len(day_events) - max_rows)}
|
||||||
|
|
||||||
|
|
||||||
|
def build_agenda(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
|
||||||
|
palette_rgb: list | None = None, weather_cities: list[dict] | None = None,
|
||||||
|
weather_units: str = "fahrenheit", theme_name: str | None = None) -> Image.Image:
|
||||||
|
"""HTML/CSS-rendered analogue of calendar_render._build_agenda. Has a
|
||||||
|
header bar -- dithered at the theme's accent_amplitude via
|
||||||
|
ordered_dither_regions."""
|
||||||
|
theme = theme_tokens.resolve_theme(theme_name, "calendar", palette_rgb)
|
||||||
|
day = datetime.now(tz).date() + timedelta(days=browse_offset)
|
||||||
|
title_size = max(14, min(target_w, target_h) // 12)
|
||||||
|
body_size = max(11, min(target_w, target_h) // 20)
|
||||||
|
weather_size = max(10, body_size - 2)
|
||||||
|
row_h = body_size + 14
|
||||||
|
unit_suffix = "F" if weather_units == "fahrenheit" else "C"
|
||||||
|
|
||||||
|
# Single day -- no cross-section alignment concern, so header_h can
|
||||||
|
# simply reflect whether THIS day actually has weather (unlike
|
||||||
|
# build_today_tomorrow/build_week's vertical layout, which must
|
||||||
|
# reserve the same header_h for every stacked section regardless).
|
||||||
|
has_weather = bool(_weather_row(weather_cities, day, weather_units))
|
||||||
|
header_h = title_size + 24 + ((weather_size + 12) if has_weather else 0)
|
||||||
|
|
||||||
|
owners_seen: list[str] = []
|
||||||
|
data = _day_section_data(day, events, tz, palette_rgb, weather_cities, weather_units, owners_seen,
|
||||||
|
target_h - header_h - MARGIN, row_h)
|
||||||
|
|
||||||
|
template = html_render._jinja_env.get_template("calendar_agenda.html.jinja")
|
||||||
|
html = template.render(
|
||||||
|
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=theme["radius"], shadow=theme["shadow"],
|
||||||
|
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
|
||||||
|
header=data["header"], title_size=title_size, header_h=header_h,
|
||||||
|
accent_start=theme["accent_hex"], accent_end=theme["accent_hex_dark"], weather_entries=data["weather_entries"],
|
||||||
|
weather_size=weather_size, unit_suffix=unit_suffix, rows=data["rows"],
|
||||||
|
more_count=data["more_count"], row_h=row_h, body_size=body_size,
|
||||||
|
)
|
||||||
|
rendered = html_render.render_html_to_image(html, target_w, target_h)
|
||||||
|
gutter = panel_style.GUTTER
|
||||||
|
accent_rect = (gutter, gutter, target_w - gutter, gutter + header_h)
|
||||||
|
return html_render.ordered_dither_regions(
|
||||||
|
rendered, palette_rgb, accent_regions=[(accent_rect, theme["accent_amplitude"])]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_today_tomorrow(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
|
||||||
|
palette_rgb: list | None = None, weather_cities: list[dict] | None = None,
|
||||||
|
weather_units: str = "fahrenheit", theme_name: str | None = None) -> Image.Image:
|
||||||
|
"""HTML/CSS-rendered analogue of calendar_render._build_today_tomorrow
|
||||||
|
-- two day-sections stacked (see _day_section_data), each with its own
|
||||||
|
header bar dithered richer via ordered_dither_regions."""
|
||||||
|
theme = theme_tokens.resolve_theme(theme_name, "calendar", palette_rgb)
|
||||||
|
start_day = datetime.now(tz).date() + timedelta(days=browse_offset)
|
||||||
|
section_h = target_h // 2
|
||||||
|
title_size = max(13, section_h // 8)
|
||||||
|
body_size = max(10, min(target_w, target_h) // 26)
|
||||||
|
weather_size = max(9, body_size - 2)
|
||||||
|
row_h = body_size + 12
|
||||||
|
unit_suffix = "F" if weather_units == "fahrenheit" else "C"
|
||||||
|
|
||||||
|
day_dates = [start_day + timedelta(days=i) for i in range(2)]
|
||||||
|
# Uniform across both stacked sections regardless of which day(s)
|
||||||
|
# actually have weather -- see _day_section_data's own docstring for
|
||||||
|
# why a per-day header height misaligns where rows start.
|
||||||
|
any_weather = any(_weather_row(weather_cities, d, weather_units) for d in day_dates)
|
||||||
|
header_h = title_size + 16 + ((weather_size + 10) if any_weather else 0)
|
||||||
|
|
||||||
|
owners_seen: list[str] = []
|
||||||
|
days = [
|
||||||
|
_day_section_data(d, events, tz, palette_rgb, weather_cities, weather_units, owners_seen,
|
||||||
|
section_h - header_h, row_h)
|
||||||
|
for d in day_dates
|
||||||
|
]
|
||||||
|
|
||||||
|
template = html_render._jinja_env.get_template("calendar_today_tomorrow.html.jinja")
|
||||||
|
html = template.render(
|
||||||
|
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=theme["radius"], shadow=theme["shadow"],
|
||||||
|
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
|
||||||
|
days=days, title_size=title_size, header_h=header_h,
|
||||||
|
accent_start=theme["accent_hex"], accent_end=theme["accent_hex_dark"], weather_size=weather_size,
|
||||||
|
unit_suffix=unit_suffix, row_h=row_h, body_size=body_size,
|
||||||
|
)
|
||||||
|
rendered = html_render.render_html_to_image(html, target_w, target_h)
|
||||||
|
gutter = panel_style.GUTTER
|
||||||
|
accent_regions = [
|
||||||
|
((gutter, gutter + i * section_h, target_w - gutter, gutter + i * section_h + header_h),
|
||||||
|
theme["accent_amplitude"])
|
||||||
|
for i in range(len(days))
|
||||||
|
]
|
||||||
|
return html_render.ordered_dither_regions(rendered, palette_rgb, accent_regions=accent_regions)
|
||||||
|
|
||||||
|
|
||||||
|
def build_week(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
|
||||||
|
week_start: int, palette_rgb: list | None = None, weather_cities: list[dict] | None = None,
|
||||||
|
weather_units: str = "fahrenheit", days: int = 7, layout: str = "horizontal",
|
||||||
|
start_offset: int = 0, theme_name: str | None = None) -> Image.Image:
|
||||||
|
"""HTML/CSS-rendered analogue of calendar_render._build_week -- both
|
||||||
|
the vertical (stacked day-sections, reusing build_today_tomorrow's
|
||||||
|
template with an arbitrary day count) and horizontal (side-by-side
|
||||||
|
columns) layouts. Each header (per-section or per-column) dithers
|
||||||
|
richer via ordered_dither_regions."""
|
||||||
|
theme = theme_tokens.resolve_theme(theme_name, "calendar", palette_rgb)
|
||||||
|
gutter = panel_style.GUTTER
|
||||||
|
today = datetime.now(tz).date()
|
||||||
|
if days == 7:
|
||||||
|
days_since_start = (today.weekday() - week_start) % 7
|
||||||
|
week_first_day = today - timedelta(days=days_since_start) + timedelta(days=days * browse_offset)
|
||||||
|
else:
|
||||||
|
week_first_day = today + timedelta(days=start_offset) + timedelta(days=days * browse_offset)
|
||||||
|
unit_suffix = "F" if weather_units == "fahrenheit" else "C"
|
||||||
|
owners_seen: list[str] = []
|
||||||
|
|
||||||
|
if layout == "vertical":
|
||||||
|
section_h = target_h // days
|
||||||
|
title_size = max(11, min(20, section_h // 6))
|
||||||
|
body_size = max(9, min(target_w, target_h) // (18 + days))
|
||||||
|
weather_size = max(8, body_size - 2)
|
||||||
|
row_h = body_size + 10
|
||||||
|
day_dates = [week_first_day + timedelta(days=i) for i in range(days)]
|
||||||
|
# Uniform across all `days` stacked sections -- see
|
||||||
|
# _day_section_data's own docstring for why a per-day header
|
||||||
|
# height misaligns where rows start.
|
||||||
|
any_weather = any(_weather_row(weather_cities, d, weather_units) for d in day_dates)
|
||||||
|
header_h = title_size + 12 + ((weather_size + 8) if any_weather else 0)
|
||||||
|
day_sections = [
|
||||||
|
_day_section_data(d, events, tz, palette_rgb, weather_cities, weather_units, owners_seen,
|
||||||
|
section_h - header_h, row_h)
|
||||||
|
for d in day_dates
|
||||||
|
]
|
||||||
|
template = html_render._jinja_env.get_template("calendar_today_tomorrow.html.jinja")
|
||||||
|
html = template.render(
|
||||||
|
w=target_w, h=target_h, gutter=gutter, radius=theme["radius"], shadow=theme["shadow"],
|
||||||
|
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
|
||||||
|
days=day_sections, title_size=title_size, header_h=header_h,
|
||||||
|
accent_start=theme["accent_hex"], accent_end=theme["accent_hex_dark"], weather_size=weather_size,
|
||||||
|
unit_suffix=unit_suffix, row_h=row_h, body_size=body_size,
|
||||||
|
)
|
||||||
|
rendered = html_render.render_html_to_image(html, target_w, target_h)
|
||||||
|
accent_regions = [
|
||||||
|
((gutter, gutter + i * section_h, target_w - gutter, gutter + i * section_h + header_h),
|
||||||
|
theme["accent_amplitude"])
|
||||||
|
for i in range(len(day_sections))
|
||||||
|
]
|
||||||
|
return html_render.ordered_dither_regions(rendered, palette_rgb, accent_regions=accent_regions)
|
||||||
|
|
||||||
|
header_size = max(10, min(16, (target_w // days) // 6))
|
||||||
|
chip_size = max(9, header_size - 3)
|
||||||
|
weather_size = max(8, chip_size - 1)
|
||||||
|
col_w = max(1, (target_w - panel_style.GUTTER * 2) // days)
|
||||||
|
row_h = chip_size + 8
|
||||||
|
# Reserve weather-line room in every column's header uniformly
|
||||||
|
# (whether or not THIS specific day has a cached forecast) -- a
|
||||||
|
# per-column height that depends on that day's own data would
|
||||||
|
# misalign where each column's event rows start across the week
|
||||||
|
# grid the moment any single day lacks a forecast entry.
|
||||||
|
header_h = header_size + 22 + (weather_size + 6 if weather_cities else 0)
|
||||||
|
max_rows = max(0, (target_h - panel_style.GUTTER * 2 - header_h) // row_h)
|
||||||
|
|
||||||
|
cols = []
|
||||||
|
for i in range(days):
|
||||||
|
day = week_first_day + timedelta(days=i)
|
||||||
|
label = day.strftime("%a %-d") if day != today else f"★ {day.strftime('%a %-d')}"
|
||||||
|
weather_entries = _weather_row(weather_cities, day, weather_units)
|
||||||
|
day_events = _events_on_day(events, day, tz)
|
||||||
|
rows = []
|
||||||
|
for event in day_events[:max_rows]:
|
||||||
|
color = html_render._rgb_to_hex(_event_colors(event, owners_seen, palette_rgb)[0])
|
||||||
|
summary = event["summary"] if event["all_day"] else f"{_fmt_time(_event_start(event, tz))[:-3]} {event['summary']}"
|
||||||
|
rows.append({"color": color, "summary": summary})
|
||||||
|
cols.append({
|
||||||
|
"label": label, "weather": weather_entries[0] if weather_entries else None,
|
||||||
|
"rows": rows, "more_count": max(0, len(day_events) - max_rows),
|
||||||
|
})
|
||||||
|
|
||||||
|
template = html_render._jinja_env.get_template("calendar_week_horizontal.html.jinja")
|
||||||
|
html = template.render(
|
||||||
|
w=target_w, h=target_h, gutter=gutter, radius=theme["radius"], shadow=theme["shadow"],
|
||||||
|
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
|
||||||
|
cols=cols, header_size=header_size, chip_size=chip_size,
|
||||||
|
header_h=header_h, weather_size=weather_size, unit_suffix=unit_suffix,
|
||||||
|
accent_start=theme["accent_hex"], accent_end=theme["accent_hex_dark"],
|
||||||
|
)
|
||||||
|
rendered = html_render.render_html_to_image(html, target_w, target_h)
|
||||||
|
accent_rect = (gutter, gutter, target_w - gutter, gutter + header_h)
|
||||||
|
return html_render.ordered_dither_regions(rendered, palette_rgb, accent_regions=[(accent_rect, theme["accent_amplitude"])])
|
||||||
|
|
||||||
|
|
||||||
|
def build_month(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
|
||||||
|
week_start: int, palette_rgb: list | None = None, theme_name: str | None = None) -> Image.Image:
|
||||||
|
"""HTML/CSS-rendered analogue of calendar_render._build_month --
|
||||||
|
density dots per day, not literal event text, same reasoning as the
|
||||||
|
classic renderer (real text at typical month-cell size is close to
|
||||||
|
unreadable on a 6-color dithered e-ink panel). The per-owner event
|
||||||
|
dots are identity-coding (like every other calendar view's chips) and
|
||||||
|
are never touched by a theme; only the weekday-name row (a flat
|
||||||
|
accent background, no gradient in this view) dithers richer via
|
||||||
|
ordered_dither_regions."""
|
||||||
|
theme = theme_tokens.resolve_theme(theme_name, "calendar", palette_rgb)
|
||||||
|
gutter = panel_style.GUTTER
|
||||||
|
today = datetime.now(tz).date()
|
||||||
|
target_month = _add_months(date(today.year, today.month, 1), browse_offset)
|
||||||
|
weeks_dates = list(
|
||||||
|
calendar_module.Calendar(firstweekday=week_start).monthdatescalendar(target_month.year, target_month.month)
|
||||||
|
)
|
||||||
|
day_names = [n[:3] for n in (WEEKDAY_NAMES[week_start:] + WEEKDAY_NAMES[:week_start])]
|
||||||
|
|
||||||
|
header_size = max(11, min(16, target_h // 30))
|
||||||
|
day_size = max(10, min(15, target_w // 55))
|
||||||
|
dot_size = max(4, day_size // 2)
|
||||||
|
|
||||||
|
owners_seen: list[str] = []
|
||||||
|
weeks = []
|
||||||
|
for week in weeks_dates:
|
||||||
|
row = []
|
||||||
|
for day in week:
|
||||||
|
day_events = _events_on_day(events, day, tz)
|
||||||
|
dots = [html_render._rgb_to_hex(_event_colors(e, owners_seen, palette_rgb)[0]) for e in day_events[:4]]
|
||||||
|
row.append({
|
||||||
|
"day_num": day.day, "in_month": day.month == target_month.month,
|
||||||
|
"is_today": day == today, "dots": dots, "more_count": max(0, len(day_events) - 4),
|
||||||
|
})
|
||||||
|
weeks.append(row)
|
||||||
|
|
||||||
|
template = html_render._jinja_env.get_template("calendar_month.html.jinja")
|
||||||
|
html = template.render(
|
||||||
|
w=target_w, h=target_h, gutter=gutter, radius=theme["radius"], shadow=theme["shadow"],
|
||||||
|
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
|
||||||
|
day_names=day_names, weeks=weeks,
|
||||||
|
header_size=header_size, day_size=day_size, dot_size=dot_size, accent_start=theme["accent_hex"],
|
||||||
|
)
|
||||||
|
rendered = html_render.render_html_to_image(html, target_w, target_h)
|
||||||
|
weekday_row_h = header_size + 12
|
||||||
|
accent_rect = (gutter, gutter, target_w - gutter, gutter + weekday_row_h)
|
||||||
|
return html_render.ordered_dither_regions(rendered, palette_rgb,
|
||||||
|
accent_regions=[(accent_rect, theme["accent_amplitude"])])
|
||||||
|
|
||||||
|
|
||||||
|
def build(events: list[dict], view: str, browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
|
||||||
|
week_start: int, palette_rgb: list | None = None, weather_cities: list[dict] | None = None,
|
||||||
|
weather_units: str = "fahrenheit", week_days: int = 7, week_layout: str = "horizontal",
|
||||||
|
week_start_offset: int = 0, theme_name: str | None = None) -> Image.Image:
|
||||||
|
"""Dispatches to the right build_* -- mirrors calendar_render._build's
|
||||||
|
exact "month falls back to agenda when it doesn't fit" resolution, so
|
||||||
|
a narrow month-mode widget set to modern style still gets a sensible
|
||||||
|
modern view instead of erroring or silently reverting to classic."""
|
||||||
|
effective_view = view
|
||||||
|
if view == "month" and not _month_view_fits(target_w, target_h):
|
||||||
|
effective_view = "agenda"
|
||||||
|
|
||||||
|
if effective_view == "agenda":
|
||||||
|
return build_agenda(events, browse_offset, target_w, target_h, tz, palette_rgb, weather_cities,
|
||||||
|
weather_units, theme_name)
|
||||||
|
if effective_view == "today_tomorrow":
|
||||||
|
return build_today_tomorrow(events, browse_offset, target_w, target_h, tz, palette_rgb, weather_cities,
|
||||||
|
weather_units, theme_name)
|
||||||
|
if effective_view == "week":
|
||||||
|
return build_week(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb, weather_cities,
|
||||||
|
weather_units, week_days, week_layout, week_start_offset, theme_name)
|
||||||
|
return build_month(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb, theme_name)
|
||||||
+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,571 @@
|
|||||||
|
"""Experimental "modern" render style, offered as an opt-in alternative
|
||||||
|
to several widget types' hand-drawn PIL primitives: Jinja2 + a
|
||||||
|
persistent headless Chromium browser (Playwright) -- see docs/widgets.md
|
||||||
|
for the design rationale (gradients/shadows/soft shading that PIL can't
|
||||||
|
easily do, at the cost of a real browser-process dependency). Everything
|
||||||
|
in this module is shared infrastructure (the persistent browser, ordered
|
||||||
|
dithering) plus one `build_*` function per widget type that has a
|
||||||
|
modern-style builder -- battery/text/tasks/static image/whiteboard live
|
||||||
|
here directly (mirroring how those widget types are themselves "inlined"
|
||||||
|
in their own widget.py rather than getting a dedicated render module);
|
||||||
|
calendar's (all four view modes, see calendar_html_render.py) is the one
|
||||||
|
exception, kept separate the same reason calendar_render.py itself is
|
||||||
|
its own 800+ line file rather than joining battery/text/tasks inline.
|
||||||
|
Not offered for the photos widget -- a real photograph isn't a
|
||||||
|
synthesized dashboard card, and photos has its own separate palette/
|
||||||
|
dithering concern instead (see widgets/photos.py).
|
||||||
|
|
||||||
|
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, theme_tokens
|
||||||
|
from .image_pipeline import DEFAULT_PALETTE_RGB, hex_to_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": "⛈️",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _rgb_to_hex(rgb: tuple[int, int, int]) -> str:
|
||||||
|
return "#%02x%02x%02x" % tuple(rgb)
|
||||||
|
|
||||||
|
|
||||||
|
def _darken_hex(rgb: tuple[int, int, int], factor: float = 0.75) -> str:
|
||||||
|
"""A darker shade of `rgb` for a CSS gradient's second stop -- purely
|
||||||
|
decorative (ordered_dither commits everything to exact palette colors
|
||||||
|
regardless of which literal hex a gradient starts from)."""
|
||||||
|
return _rgb_to_hex(tuple(max(0, round(c * factor)) for c in rgb))
|
||||||
|
|
||||||
|
|
||||||
|
# --- 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")
|
||||||
|
|
||||||
|
|
||||||
|
def ordered_dither_regions(rendered: Image.Image, palette_rgb: list | None, base_amplitude: float = 48.0,
|
||||||
|
accent_regions: list[tuple[tuple[int, int, int, int], float]] = ()) -> Image.Image:
|
||||||
|
"""Like `ordered_dither`, but lets specific rectangles (e.g. a themed
|
||||||
|
header bar) dither at a higher amplitude than the rest of the widget.
|
||||||
|
A single higher amplitude applied to a whole widget washes out pale
|
||||||
|
content (a weather icon's white cloud body nearly disappeared in
|
||||||
|
testing); dithering the base image at the safe default and only
|
||||||
|
re-dithering an accent rect on top -- pasted back over the base --
|
||||||
|
lets a header carry a rich, arbitrary accent hue (via denser
|
||||||
|
stippling) without touching icon/text legibility elsewhere. Safe to
|
||||||
|
do per-region for the same reason `ordered_dither` is safe per-widget
|
||||||
|
(see its docstring): no cross-pixel error-diffusion term, so each
|
||||||
|
region's result depends only on its own pixels."""
|
||||||
|
base = ordered_dither(rendered, palette_rgb, amplitude=base_amplitude)
|
||||||
|
for (x0, y0, x1, y1), amplitude in accent_regions:
|
||||||
|
crop = rendered.crop((x0, y0, x1, y1))
|
||||||
|
base.paste(ordered_dither(crop, palette_rgb, amplitude=amplitude), (x0, y0))
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
# --- 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 = "", theme_name: str | None = None) -> 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). No header/accent region here (just a centered
|
||||||
|
icon+temp) -- theme-aware for font/radius only, plain ordered_dither
|
||||||
|
(no ordered_dither_regions call)."""
|
||||||
|
img = Image.new("RGB", (target_w, target_h), (255, 255, 255))
|
||||||
|
if not entry:
|
||||||
|
return img
|
||||||
|
|
||||||
|
theme = theme_tokens.resolve_theme(theme_name, "weather", palette_rgb)
|
||||||
|
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=theme["radius"],
|
||||||
|
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
|
||||||
|
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 = "", theme_name: str | None = None) -> Image.Image:
|
||||||
|
"""HTML/CSS-rendered analogue of weather_render.build_daily -- same
|
||||||
|
call signature. Returns an already-palette-exact RGB image (see
|
||||||
|
ordered_dither). Has a header bar -- dithered at the theme's
|
||||||
|
accent_amplitude via ordered_dither_regions, richer than the rest of
|
||||||
|
the widget (see that function's docstring for why)."""
|
||||||
|
img = Image.new("RGB", (target_w, target_h), (255, 255, 255))
|
||||||
|
days = list(daily.items())
|
||||||
|
if not days:
|
||||||
|
return img
|
||||||
|
|
||||||
|
theme = theme_tokens.resolve_theme(theme_name, "weather", palette_rgb)
|
||||||
|
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=theme["radius"], shadow=theme["shadow"],
|
||||||
|
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
|
||||||
|
city_label=city_label, header_h=header_h,
|
||||||
|
title_size=max(14, header_h - 12), accent_start=theme["accent_hex"], accent_end=theme["accent_hex_dark"],
|
||||||
|
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)
|
||||||
|
if header_h <= 0:
|
||||||
|
return ordered_dither(rendered, palette_rgb)
|
||||||
|
gutter = panel_style.GUTTER
|
||||||
|
accent_rect = (gutter, gutter, target_w - gutter, gutter + header_h)
|
||||||
|
return ordered_dither_regions(rendered, palette_rgb, accent_regions=[(accent_rect, theme["accent_amplitude"])])
|
||||||
|
|
||||||
|
|
||||||
|
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 = "", theme_name: str | None = None) -> 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, theme_name)
|
||||||
|
return build_daily(data, target_w, target_h, palette_rgb, units, city_label, theme_name)
|
||||||
|
|
||||||
|
|
||||||
|
def render_weather_preview_png(mode: str, data, orientation: str, palette_rgb: list | None,
|
||||||
|
units: str = "fahrenheit", city_label: str = "",
|
||||||
|
theme_name: str | None = None) -> 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, theme_name)
|
||||||
|
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
|
||||||
|
return _png_bytes(quantized)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Battery "modern" style ------------------------------------------------
|
||||||
|
|
||||||
|
def _battery_sizes(target_w: int, target_h: int, num_lines: int, scale: float) -> dict:
|
||||||
|
base = min(target_w, target_h)
|
||||||
|
icon_h = max(16, int(base // 3 * scale))
|
||||||
|
pct_size = max(14, int(base // 3 * scale))
|
||||||
|
line_size = max(9, int(base // 9 * scale))
|
||||||
|
gap = 8
|
||||||
|
total = icon_h + gap + pct_size + num_lines * (line_size + gap)
|
||||||
|
return {"icon_h": icon_h, "pct_size": pct_size, "line_size": line_size, "total": total}
|
||||||
|
|
||||||
|
|
||||||
|
def build_battery(percent: int, lines: list[str], target_w: int, target_h: int,
|
||||||
|
palette_rgb: list | None = None, theme_name: str | None = None) -> Image.Image:
|
||||||
|
"""HTML/CSS-rendered analogue of widgets/battery.py's classic PIL
|
||||||
|
drawing -- same icon+percent+caption-lines shape, `lines` already
|
||||||
|
resolved by the caller (widgets/battery.py's _lines_for(), shared
|
||||||
|
with the classic path so the estimate/age formatting only lives in
|
||||||
|
one place). Returns an already-palette-exact RGB image (see
|
||||||
|
ordered_dither). Theme-aware for font/radius only -- the charge-level
|
||||||
|
fill_color below is a functional status signal (not a style choice)
|
||||||
|
and is never touched by a theme, and there's no header/accent region
|
||||||
|
to dither richer via ordered_dither_regions.
|
||||||
|
|
||||||
|
Shrinks icon/text sizes together (in 0.05 steps down to 0.3x) until
|
||||||
|
the whole stack actually fits the available height -- the classic
|
||||||
|
PIL path solves the same "icon + percent + 0-2 lines in a fixed box"
|
||||||
|
problem by truncating lines that don't fit; scaling down instead
|
||||||
|
keeps every resolved line visible, which reads better for a widget
|
||||||
|
that only ever has at most 2 short caption lines to begin with."""
|
||||||
|
avail_h = target_h - panel_style.GUTTER * 2
|
||||||
|
num_lines = len(lines)
|
||||||
|
scale = 1.0
|
||||||
|
sizes = _battery_sizes(target_w, target_h, num_lines, scale)
|
||||||
|
while sizes["total"] > avail_h and scale > 0.3:
|
||||||
|
scale -= 0.05
|
||||||
|
sizes = _battery_sizes(target_w, target_h, num_lines, scale)
|
||||||
|
# Extreme case (a 1x1-grid-cell-sized widget in "detailed" mode):
|
||||||
|
# scale bottomed out and it still doesn't fit -- drop the least
|
||||||
|
# important line rather than render overlapping text, same
|
||||||
|
# graceful-degradation idiom the classic PIL path's own
|
||||||
|
# `if y + small_font_size > ...: break` truncation already uses.
|
||||||
|
while sizes["total"] > avail_h and lines:
|
||||||
|
lines = lines[:-1]
|
||||||
|
num_lines = len(lines)
|
||||||
|
sizes = _battery_sizes(target_w, target_h, num_lines, scale)
|
||||||
|
|
||||||
|
theme = theme_tokens.resolve_theme(theme_name, "battery", palette_rgb)
|
||||||
|
fill_color = panel_style.battery_fill_color(percent, palette_rgb)
|
||||||
|
icon_h = sizes["icon_h"]
|
||||||
|
icon_w = int(icon_h * 1.8)
|
||||||
|
stroke = max(2, icon_h // 12)
|
||||||
|
nub_w = max(3, icon_w // 10)
|
||||||
|
template = _jinja_env.get_template("battery.html.jinja")
|
||||||
|
html = template.render(
|
||||||
|
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=theme["radius"],
|
||||||
|
font_regular=theme["font_regular"], font_bold=theme["font_bold"], percent=percent, lines=lines,
|
||||||
|
icon_w=icon_w, icon_h=icon_h, icon_radius=icon_h // 6, stroke=stroke,
|
||||||
|
fill_pct=max(0, min(100, percent)), fill_radius=max(0, icon_h // 6 - stroke),
|
||||||
|
fill_color=_rgb_to_hex(fill_color), fill_color_dark=_darken_hex(fill_color),
|
||||||
|
nub_w=nub_w, nub_h=icon_h // 2, nub_radius=max(1, nub_w // 3),
|
||||||
|
pct_size=sizes["pct_size"], line_size=sizes["line_size"],
|
||||||
|
)
|
||||||
|
rendered = render_html_to_image(html, target_w, target_h)
|
||||||
|
return ordered_dither(rendered, palette_rgb)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Text "modern" style ---------------------------------------------------
|
||||||
|
|
||||||
|
def build_text(cfg, target_w: int, target_h: int, palette_rgb: list | None = None,
|
||||||
|
theme_name: str | None = None) -> Image.Image:
|
||||||
|
"""HTML/CSS-rendered analogue of widgets/text.py's classic PIL
|
||||||
|
drawing. Reuses widgets/text.py's own `_fit()` for the one piece of
|
||||||
|
logic CSS has no native equivalent for (shrink-to-fit sizing) --
|
||||||
|
`_fit` measures against the exact same vendored font files via PIL,
|
||||||
|
so the resolved size is a real fit decision, not a guess -- but lets
|
||||||
|
the browser do its own text wrapping/line-breaking at that size
|
||||||
|
(paragraphs/runs passed through directly as HTML) rather than
|
||||||
|
replicating `_fit`'s own word-wrapped line list; the two wrapping
|
||||||
|
algorithms can disagree on exact break points, an acceptable
|
||||||
|
approximation since this style only needs to look good and fit
|
||||||
|
reasonably, not be pixel-identical to classic. Returns an already-
|
||||||
|
palette-exact RGB image (see ordered_dither).
|
||||||
|
|
||||||
|
theme_name is accepted (every modern-style build_* function takes
|
||||||
|
one, threaded uniformly from frame.theme) but deliberately unused --
|
||||||
|
the text widget's own font_family is a per-widget, user-authored
|
||||||
|
choice (see widgets/text.py's module docstring), same carve-out
|
||||||
|
reasoning as run-level colors; a frame theme overriding it would
|
||||||
|
silently undo an explicit user choice. text.html.jinja also has no
|
||||||
|
card chrome (no radius/shadow) for a theme to touch."""
|
||||||
|
from PIL import ImageDraw
|
||||||
|
|
||||||
|
from . import widgets # local import: heavy-ish, and only "modern" text needs it
|
||||||
|
|
||||||
|
text_widget = widgets.text
|
||||||
|
bg_rgb = (hex_to_rgb(cfg.background_color) if cfg.background_color else None) or (255, 255, 255)
|
||||||
|
family = cfg.font_family if cfg.font_family in text_widget.FONT_FAMILIES else text_widget.DEFAULT_FONT_FAMILY
|
||||||
|
paragraphs = cfg.content or []
|
||||||
|
margin = text_widget.MARGIN
|
||||||
|
max_width = max(10, target_w - 2 * margin)
|
||||||
|
max_height = max(10, target_h - 2 * margin)
|
||||||
|
|
||||||
|
measure_img = Image.new("RGB", (1, 1))
|
||||||
|
draw = ImageDraw.Draw(measure_img)
|
||||||
|
size, _lines = text_widget._fit(draw, paragraphs, family, cfg.font_size, max_width, max_height)
|
||||||
|
|
||||||
|
files = text_widget._FONT_FILES.get(family) or text_widget._FONT_FILES[text_widget.DEFAULT_FONT_FAMILY]
|
||||||
|
align = cfg.align if cfg.align in ("left", "center", "right") else "left"
|
||||||
|
template = _jinja_env.get_template("text.html.jinja")
|
||||||
|
html = template.render(
|
||||||
|
w=target_w, h=target_h, margin=margin, bg_color=_rgb_to_hex(bg_rgb),
|
||||||
|
size=size, line_height=text_widget.LINE_HEIGHT_FACTOR, align=align,
|
||||||
|
font_regular=str(_FONT_DIR / files[(False, False)]), font_bold=str(_FONT_DIR / files[(True, False)]),
|
||||||
|
font_italic=str(_FONT_DIR / files[(False, True)]), font_bold_italic=str(_FONT_DIR / files[(True, True)]),
|
||||||
|
paragraphs=paragraphs,
|
||||||
|
)
|
||||||
|
rendered = render_html_to_image(html, target_w, target_h)
|
||||||
|
return ordered_dither(rendered, palette_rgb)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Tasks "modern" style ---------------------------------------------------
|
||||||
|
|
||||||
|
def build_tasks(tasks: list[dict], target_w: int, target_h: int, palette_rgb: list | None = None,
|
||||||
|
title: str = "Tasks", theme_name: str | None = None) -> Image.Image:
|
||||||
|
"""HTML/CSS-rendered analogue of calendar_render._build_tasks --
|
||||||
|
same header+checklist shape. Reuses calendar_render's own
|
||||||
|
_event_colors/_fmt_task_due (the exact color-dedup/due-date-format
|
||||||
|
logic the classic renderer uses) so a task's color chip/due string
|
||||||
|
matches classic style exactly; only the drawing differs -- and a
|
||||||
|
theme's accent never touches those per-owner chip colors (identity-
|
||||||
|
coding, not style). Has a header bar -- dithered at the theme's
|
||||||
|
accent_amplitude via ordered_dither_regions. Returns an already-
|
||||||
|
palette-exact RGB image (see ordered_dither)."""
|
||||||
|
from .calendar_render import _event_colors, _fmt_task_due
|
||||||
|
|
||||||
|
theme = theme_tokens.resolve_theme(theme_name, "tasks", palette_rgb)
|
||||||
|
header_h = max(28, min(target_w, target_h) // 8)
|
||||||
|
body_size = max(11, min(target_w, target_h) // 20)
|
||||||
|
row_h = body_size + 14
|
||||||
|
box_size = max(10, body_size - 4)
|
||||||
|
avail_h = target_h - header_h - 16
|
||||||
|
max_rows = max(0, avail_h // row_h)
|
||||||
|
|
||||||
|
owners_seen: list[str] = []
|
||||||
|
rows = []
|
||||||
|
for task in tasks[:max_rows]:
|
||||||
|
colors = _event_colors(task, owners_seen, palette_rgb)
|
||||||
|
done = task.get("completed_at") is not None
|
||||||
|
due = None if done else (_fmt_task_due(task.get("due")) or None)
|
||||||
|
rows.append({
|
||||||
|
"colors": [_rgb_to_hex(c) for c in colors],
|
||||||
|
"done": done,
|
||||||
|
"due": due,
|
||||||
|
"summary": task["summary"],
|
||||||
|
})
|
||||||
|
more_count = max(0, len(tasks) - max_rows)
|
||||||
|
|
||||||
|
template = _jinja_env.get_template("tasks.html.jinja")
|
||||||
|
html = template.render(
|
||||||
|
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=theme["radius"], shadow=theme["shadow"],
|
||||||
|
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
|
||||||
|
title=title, header_h=header_h, title_size=max(14, header_h - 12),
|
||||||
|
accent_start=theme["accent_hex"], accent_end=theme["accent_hex_dark"],
|
||||||
|
rows=rows, more_count=more_count, row_h=row_h, box_size=box_size, body_size=body_size,
|
||||||
|
)
|
||||||
|
rendered = render_html_to_image(html, target_w, target_h)
|
||||||
|
gutter = panel_style.GUTTER
|
||||||
|
accent_rect = (gutter, gutter, target_w - gutter, gutter + header_h)
|
||||||
|
return ordered_dither_regions(rendered, palette_rgb, accent_regions=[(accent_rect, theme["accent_amplitude"])])
|
||||||
|
|
||||||
|
|
||||||
|
# --- Static image / whiteboard "modern" style (shared) ---------------------
|
||||||
|
|
||||||
|
def build_framed_image(composed: Image.Image, target_w: int, target_h: int,
|
||||||
|
palette_rgb: list | None = None, theme_name: str | None = None,
|
||||||
|
widget_kind: str = "static") -> Image.Image:
|
||||||
|
"""Wraps an already-composed image (static_image.py/whiteboard.py's
|
||||||
|
own compose_into() output, exactly target_w x target_h, already
|
||||||
|
cropped/fit per that widget's own display_mode) in a rounded-corner,
|
||||||
|
shadowed card -- the first visual chrome either widget type has ever
|
||||||
|
had (both currently draw with zero chrome of their own). Theme-aware
|
||||||
|
for radius/shadow only -- no text/header content to accent or font.
|
||||||
|
Returns an already-palette-exact RGB image (see ordered_dither)."""
|
||||||
|
import base64
|
||||||
|
|
||||||
|
theme = theme_tokens.resolve_theme(theme_name, widget_kind, palette_rgb)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
composed.convert("RGB").save(buf, format="PNG")
|
||||||
|
image_b64 = base64.b64encode(buf.getvalue()).decode("ascii")
|
||||||
|
|
||||||
|
template = _jinja_env.get_template("framed_image.html.jinja")
|
||||||
|
html = template.render(
|
||||||
|
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=theme["radius"], shadow=theme["shadow"],
|
||||||
|
image_b64=image_b64,
|
||||||
|
)
|
||||||
|
rendered = render_html_to_image(html, target_w, target_h)
|
||||||
|
return ordered_dither(rendered, palette_rgb)
|
||||||
@@ -61,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
|
||||||
@@ -74,23 +75,43 @@ 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)
|
||||||
|
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)
|
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:
|
||||||
|
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)
|
draw.rectangle([inset, inset, w - 1 - inset, h - 1 - inset], outline=color, width=line_t)
|
||||||
return
|
return
|
||||||
|
|
||||||
if style == "solid":
|
if style == "solid":
|
||||||
|
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)
|
draw.rectangle([0, 0, w - 1, h - 1], outline=color, width=t)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -166,6 +187,27 @@ DEFAULT_PALETTE_RGB = [
|
|||||||
|
|
||||||
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
|
||||||
@@ -496,9 +538,16 @@ def render_frame(source: Image.Image, faces: list[dict] | None = None,
|
|||||||
return _transpose_and_pack(quantized, orientation)
|
return _transpose_and_pack(quantized, orientation)
|
||||||
|
|
||||||
|
|
||||||
|
def _png_bytes(img: Image.Image) -> bytes:
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.convert("RGB").save(buf, format="PNG")
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
def render_panel(regions: list[tuple[tuple[int, int, int, int], Image.Image]], orientation: str = "landscape",
|
def render_panel(regions: list[tuple[tuple[int, int, int, int], Image.Image]], orientation: str = "landscape",
|
||||||
palette_rgb: list | None = None, color_boost: float = 1.0, contrast_boost: float = 1.0,
|
palette_rgb: list | None = None, color_boost: float = 1.0, contrast_boost: float = 1.0,
|
||||||
dither_strength: float = 1.0, manage: dict | None = None, as_png: bool = False) -> bytes:
|
dither_strength: float = 1.0, manage: dict | None = None, as_png: bool = False,
|
||||||
|
capture_snapshot: bool = False) -> bytes | tuple[bytes, bytes]:
|
||||||
"""The widget system's compositor -- generalizes render_frame's tail
|
"""The widget system's compositor -- generalizes render_frame's tail
|
||||||
(paste, enhance once, overlay once, quantize once, pack once) from
|
(paste, enhance once, overlay once, quantize once, pack once) from
|
||||||
"compose one photo" to "paste N already-rendered regions, then run
|
"compose one photo" to "paste N already-rendered regions, then run
|
||||||
@@ -530,7 +579,14 @@ def render_panel(regions: list[tuple[tuple[int, int, int, int], Image.Image]], o
|
|||||||
as_png=True returns a normal browser-viewable PNG in logical (upright)
|
as_png=True returns a normal browser-viewable PNG in logical (upright)
|
||||||
orientation instead of packed native-panel bytes, same convention as
|
orientation instead of packed native-panel bytes, same convention as
|
||||||
render_preview_png -- used for the web UI's live "how it's displaying"
|
render_preview_png -- used for the web UI's live "how it's displaying"
|
||||||
thumbnail."""
|
thumbnail.
|
||||||
|
|
||||||
|
capture_snapshot=True (only meaningful alongside as_png=False) returns
|
||||||
|
(packed_bytes, png_bytes) instead of just packed_bytes -- both derived
|
||||||
|
from the same already-quantized canvas, so a device-facing render can
|
||||||
|
also persist a browser-viewable copy (see routers/device.py's
|
||||||
|
_record_last_displayed) without re-running composition/quantization a
|
||||||
|
second time."""
|
||||||
logical_w, logical_h = logical_render_size(orientation)
|
logical_w, logical_h = logical_render_size(orientation)
|
||||||
canvas = Image.new("RGB", (logical_w, logical_h), LETTERBOX_BG)
|
canvas = Image.new("RGB", (logical_w, logical_h), LETTERBOX_BG)
|
||||||
for (x, y, w, h), region_img in regions:
|
for (x, y, w, h), region_img in regions:
|
||||||
@@ -540,10 +596,11 @@ def render_panel(regions: list[tuple[tuple[int, int, int, int], Image.Image]], o
|
|||||||
fitted = _apply_manage_overlay(fitted, manage)
|
fitted = _apply_manage_overlay(fitted, manage)
|
||||||
quantized = _quantize(fitted, palette_rgb, dither_strength)
|
quantized = _quantize(fitted, palette_rgb, dither_strength)
|
||||||
if as_png:
|
if as_png:
|
||||||
buf = io.BytesIO()
|
return _png_bytes(quantized)
|
||||||
quantized.convert("RGB").save(buf, format="PNG")
|
packed = _transpose_and_pack(quantized, orientation)
|
||||||
return buf.getvalue()
|
if capture_snapshot:
|
||||||
return _transpose_and_pack(quantized, orientation)
|
return packed, _png_bytes(quantized)
|
||||||
|
return packed
|
||||||
|
|
||||||
|
|
||||||
def render_preview_png(source: Image.Image, faces: list[dict] | None = None,
|
def render_preview_png(source: Image.Image, faces: list[dict] | None = None,
|
||||||
@@ -559,14 +616,13 @@ def render_preview_png(source: Image.Image, faces: list[dict] | None = None,
|
|||||||
fitted = _enhance(_compose(source, faces, orientation, display_mode), color_boost, contrast_boost)
|
fitted = _enhance(_compose(source, faces, orientation, display_mode), color_boost, contrast_boost)
|
||||||
fitted = _apply_manage_overlay(fitted, manage)
|
fitted = _apply_manage_overlay(fitted, manage)
|
||||||
quantized = _quantize(fitted, palette_rgb, dither_strength)
|
quantized = _quantize(fitted, palette_rgb, dither_strength)
|
||||||
buf = io.BytesIO()
|
return _png_bytes(quantized)
|
||||||
quantized.convert("RGB").save(buf, format="PNG")
|
|
||||||
return buf.getvalue()
|
|
||||||
|
|
||||||
|
|
||||||
def render_placeholder(lines: list[str], qr_url: str | None = None,
|
def render_placeholder(lines: list[str], qr_url: str | None = None,
|
||||||
orientation: str = "landscape", palette_rgb: list | None = None,
|
orientation: str = "landscape", palette_rgb: list | None = None,
|
||||||
manage: dict | None = None, as_png: bool = False) -> bytes:
|
manage: dict | None = None, as_png: bool = False,
|
||||||
|
capture_snapshot: bool = False) -> bytes | tuple[bytes, bytes]:
|
||||||
"""A readable full-panel message (plus an optional QR code) in the
|
"""A readable full-panel message (plus an optional QR code) in the
|
||||||
same packed format as render_frame -- what /frame/image serves for a
|
same packed format as render_frame -- what /frame/image serves for a
|
||||||
frame that isn't claimed or configured yet, so a fresh device shows
|
frame that isn't claimed or configured yet, so a fresh device shows
|
||||||
@@ -574,7 +630,8 @@ def render_placeholder(lines: list[str], qr_url: str | None = None,
|
|||||||
|
|
||||||
`manage`, same as render_frame's -- lets the manage button still work
|
`manage`, same as render_frame's -- lets the manage button still work
|
||||||
(at minimum, the scan-to-manage QR) on a frame that isn't configured
|
(at minimum, the scan-to-manage QR) on a frame that isn't configured
|
||||||
yet."""
|
yet. `capture_snapshot`, same as render_panel's -- (packed, png)
|
||||||
|
instead of just packed."""
|
||||||
margin = 24
|
margin = 24
|
||||||
logical_w, logical_h = logical_render_size(orientation)
|
logical_w, logical_h = logical_render_size(orientation)
|
||||||
img = Image.new("RGB", (logical_w, logical_h), (255, 255, 255))
|
img = Image.new("RGB", (logical_w, logical_h), (255, 255, 255))
|
||||||
@@ -638,7 +695,8 @@ def render_placeholder(lines: list[str], qr_url: str | None = None,
|
|||||||
img = _apply_manage_overlay(img, manage)
|
img = _apply_manage_overlay(img, manage)
|
||||||
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
|
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
|
||||||
if as_png:
|
if as_png:
|
||||||
buf = io.BytesIO()
|
return _png_bytes(quantized)
|
||||||
quantized.convert("RGB").save(buf, format="PNG")
|
packed = _transpose_and_pack(quantized, orientation)
|
||||||
return buf.getvalue()
|
if capture_snapshot:
|
||||||
return _transpose_and_pack(quantized, orientation)
|
return packed, _png_bytes(quantized)
|
||||||
|
return packed
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""Root-logger configuration: a rotating file handler under the same
|
||||||
|
/data volume as the sqlite DB and legacy config.json, so the admin log
|
||||||
|
viewer has something to read and log content survives container
|
||||||
|
restarts -- a redeploy happens on every push to main touching
|
||||||
|
server/**, which would make an in-memory-only log buffer nearly
|
||||||
|
useless in practice. Before this, the root logger had no handler at
|
||||||
|
all, so every module's logger.info() call (user creation, claims,
|
||||||
|
password resets, ...) was silently dropped rather than merely
|
||||||
|
un-viewable -- this fixes that too, not just adds a viewer."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
LOG_PATH = Path(os.environ.get("LOG_PATH", "/data/server.log"))
|
||||||
|
|
||||||
|
|
||||||
|
def configure_logging() -> None:
|
||||||
|
LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
handler = RotatingFileHandler(LOG_PATH, maxBytes=2_000_000, backupCount=3)
|
||||||
|
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
|
||||||
|
root = logging.getLogger()
|
||||||
|
root.addHandler(handler)
|
||||||
|
root.setLevel(os.environ.get("LOG_LEVEL", "INFO"))
|
||||||
|
|
||||||
|
|
||||||
|
def read_log_tail(lines: int) -> str:
|
||||||
|
if not LOG_PATH.exists():
|
||||||
|
return ""
|
||||||
|
text = LOG_PATH.read_text(errors="replace")
|
||||||
|
return "\n".join(text.splitlines()[-lines:])
|
||||||
+53
-3
@@ -16,14 +16,16 @@ pre-database config.json deployment on first boot."""
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
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 migration
|
from . import html_render, logging_setup, migration
|
||||||
from .auth import (
|
from .auth import (
|
||||||
browser_token_valid,
|
browser_token_valid,
|
||||||
current_user,
|
current_user,
|
||||||
@@ -38,12 +40,53 @@ from .routers.common import shell_context
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Before anything else logs: a handler exists to catch it, and it lands in
|
||||||
|
# the same persistent volume the admin log viewer reads from.
|
||||||
|
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")
|
||||||
|
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def log_device_requests(request: Request, call_next):
|
||||||
|
"""Access log for the firmware-facing /frame/* protocol -- the admin
|
||||||
|
log viewer otherwise only ever shows exceptions (device.py logs
|
||||||
|
those, not successful requests), so a slow-but-200 request or a
|
||||||
|
device hammering a stale/wrong token leaves no trace at all. Logs
|
||||||
|
the device id (query param, not the token -- never log credentials)
|
||||||
|
and wall time, which is exactly what's needed to spot a request that
|
||||||
|
blew past the firmware's fixed HTTP timeout without technically
|
||||||
|
failing server-side."""
|
||||||
|
if not request.url.path.startswith("/frame/"):
|
||||||
|
return await call_next(request)
|
||||||
|
start = time.monotonic()
|
||||||
|
device_id = request.query_params.get("id", "") or "-"
|
||||||
|
response = await call_next(request)
|
||||||
|
elapsed_ms = (time.monotonic() - start) * 1000
|
||||||
|
logger.info(
|
||||||
|
"%s %s id=%s -> %d (%.0fms)",
|
||||||
|
request.method, request.url.path, device_id, response.status_code, elapsed_ms,
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||||
|
|
||||||
app.include_router(device.router)
|
app.include_router(device.router)
|
||||||
@@ -60,6 +103,13 @@ def health() -> dict:
|
|||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/sw.js")
|
||||||
|
def service_worker() -> FileResponse:
|
||||||
|
# Served from / rather than /static/sw.js so its default scope is the
|
||||||
|
# whole app -- a SW can only ever control paths at or below its own URL.
|
||||||
|
return FileResponse("app/static/sw.js", media_type="application/javascript")
|
||||||
|
|
||||||
|
|
||||||
def _device_credential_redirect(request: Request, db, allow_legacy: bool) -> str | None:
|
def _device_credential_redirect(request: Request, db, allow_legacy: bool) -> str | None:
|
||||||
"""The on-frame manage QR points at the server root with the device's
|
"""The on-frame manage QR points at the server root with the device's
|
||||||
own credentials (new firmware: ?id=&token=; deployed firmware:
|
own credentials (new firmware: ?id=&token=; deployed firmware:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -774,6 +774,121 @@ def _migration_29(conn) -> None:
|
|||||||
conn.execute(text("ALTER TABLE frames ADD COLUMN last_cycled_layout_id INTEGER"))
|
conn.execute(text("ALTER TABLE frames ADD COLUMN last_cycled_layout_id INTEGER"))
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_30(conn) -> None:
|
||||||
|
""""Now displaying" (models.Frame.last_displayed_image/
|
||||||
|
last_displayed_at) -- the web UI's header preview pair needs a frozen
|
||||||
|
record of exactly what the last device-facing render actually sent,
|
||||||
|
separate from the always-live "up next" re-render (see
|
||||||
|
routers/device.py's _record_last_displayed, api_frames.py's
|
||||||
|
/now-displaying endpoint). NULL/0.0 for every existing frame until
|
||||||
|
its next real device fetch -- no behavior change to what's served,
|
||||||
|
only a new thing recorded alongside it.
|
||||||
|
|
||||||
|
Guarded per-column, same reasoning as migration 26/27/29's own
|
||||||
|
comments: frames is a table test_migrations.py's pre-widget-system
|
||||||
|
replay tests leave un-dropped, so it keeps the fresh-install
|
||||||
|
create_all() copy -- which already has these columns -- when those
|
||||||
|
tests replay migrations 17+ from schema_version 16. Without the
|
||||||
|
guard, replaying this migration there re-adds a column that's already
|
||||||
|
there and SQLite raises "duplicate column name"."""
|
||||||
|
existing = {c["name"] for c in inspect(conn).get_columns("frames")}
|
||||||
|
if "last_displayed_image" not in existing:
|
||||||
|
conn.execute(text("ALTER TABLE frames ADD COLUMN last_displayed_image BLOB"))
|
||||||
|
if "last_displayed_at" not in existing:
|
||||||
|
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'"))
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_32(conn) -> None:
|
||||||
|
"""Photos widget's own independent palette/dithering (models.Frame.
|
||||||
|
photo_palette_rgb/photo_dither_strength -- see widgets/photos.py's
|
||||||
|
render()). NULL/1.0 defaults reproduce the exact previous rendering
|
||||||
|
(same reference palette/strength as the main fields) until a frame's
|
||||||
|
Configuration tab sets them differently.
|
||||||
|
|
||||||
|
Guarded per-column, same reasoning as migration 30/31's own comments."""
|
||||||
|
existing = {c["name"] for c in inspect(conn).get_columns("frames")}
|
||||||
|
if "photo_palette_rgb" not in existing:
|
||||||
|
conn.execute(text("ALTER TABLE frames ADD COLUMN photo_palette_rgb TEXT"))
|
||||||
|
if "photo_dither_strength" not in existing:
|
||||||
|
conn.execute(text("ALTER TABLE frames ADD COLUMN photo_dither_strength REAL NOT NULL DEFAULT 1.0"))
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_33(conn) -> None:
|
||||||
|
"""Battery widget render style (models.BatteryWidgetConfig.
|
||||||
|
render_style) -- same shape as migration 31's weather one. Every
|
||||||
|
existing battery widget defaults to "classic", no behavior change."""
|
||||||
|
existing = {c["name"] for c in inspect(conn).get_columns("battery_widget_configs")}
|
||||||
|
if "render_style" not in existing:
|
||||||
|
conn.execute(text("ALTER TABLE battery_widget_configs ADD COLUMN render_style TEXT NOT NULL DEFAULT 'classic'"))
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_34(conn) -> None:
|
||||||
|
"""Text widget render style (models.TextWidgetConfig.render_style) --
|
||||||
|
same shape as migration 31/33. Every existing text widget defaults to
|
||||||
|
"classic", no behavior change."""
|
||||||
|
existing = {c["name"] for c in inspect(conn).get_columns("text_widget_configs")}
|
||||||
|
if "render_style" not in existing:
|
||||||
|
conn.execute(text("ALTER TABLE text_widget_configs ADD COLUMN render_style TEXT NOT NULL DEFAULT 'classic'"))
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_35(conn) -> None:
|
||||||
|
"""Tasks widget render style (models.TaskWidgetConfig.render_style)
|
||||||
|
-- same shape as migration 31/33/34. Every existing tasks widget
|
||||||
|
defaults to "classic", no behavior change."""
|
||||||
|
existing = {c["name"] for c in inspect(conn).get_columns("task_widget_configs")}
|
||||||
|
if "render_style" not in existing:
|
||||||
|
conn.execute(text("ALTER TABLE task_widget_configs ADD COLUMN render_style TEXT NOT NULL DEFAULT 'classic'"))
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_36(conn) -> None:
|
||||||
|
"""Static image widget render style (models.StaticWidgetConfig.
|
||||||
|
render_style) -- same shape as migration 31/33/34/35."""
|
||||||
|
existing = {c["name"] for c in inspect(conn).get_columns("static_widget_configs")}
|
||||||
|
if "render_style" not in existing:
|
||||||
|
conn.execute(text("ALTER TABLE static_widget_configs ADD COLUMN render_style TEXT NOT NULL DEFAULT 'classic'"))
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_37(conn) -> None:
|
||||||
|
"""Whiteboard widget render style (models.WhiteboardWidgetConfig.
|
||||||
|
render_style) -- same shape as migration 36."""
|
||||||
|
existing = {c["name"] for c in inspect(conn).get_columns("whiteboard_widget_configs")}
|
||||||
|
if "render_style" not in existing:
|
||||||
|
conn.execute(text("ALTER TABLE whiteboard_widget_configs ADD COLUMN render_style TEXT NOT NULL DEFAULT 'classic'"))
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_38(conn) -> None:
|
||||||
|
"""Calendar widget render style (models.CalendarWidgetConfig.
|
||||||
|
render_style) -- same shape as migration 31/33/34/35/36/37."""
|
||||||
|
existing = {c["name"] for c in inspect(conn).get_columns("calendar_widget_configs")}
|
||||||
|
if "render_style" not in existing:
|
||||||
|
conn.execute(text("ALTER TABLE calendar_widget_configs ADD COLUMN render_style TEXT NOT NULL DEFAULT 'classic'"))
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_39(conn) -> None:
|
||||||
|
"""Frame-level curated theme for "modern" style widgets (models.
|
||||||
|
Frame.theme, see theme_tokens.THEMES) -- same guarded-per-column
|
||||||
|
shape as every prior migration."""
|
||||||
|
existing = {c["name"] for c in inspect(conn).get_columns("frames")}
|
||||||
|
if "theme" not in existing:
|
||||||
|
conn.execute(text("ALTER TABLE frames ADD COLUMN theme TEXT NOT NULL DEFAULT 'classic'"))
|
||||||
|
|
||||||
|
|
||||||
MIGRATIONS = [
|
MIGRATIONS = [
|
||||||
(1, _migration_1),
|
(1, _migration_1),
|
||||||
(2, _migration_2),
|
(2, _migration_2),
|
||||||
@@ -804,6 +919,16 @@ MIGRATIONS = [
|
|||||||
(27, _migration_27),
|
(27, _migration_27),
|
||||||
(28, _migration_28),
|
(28, _migration_28),
|
||||||
(29, _migration_29),
|
(29, _migration_29),
|
||||||
|
(30, _migration_30),
|
||||||
|
(31, _migration_31),
|
||||||
|
(32, _migration_32),
|
||||||
|
(33, _migration_33),
|
||||||
|
(34, _migration_34),
|
||||||
|
(35, _migration_35),
|
||||||
|
(36, _migration_36),
|
||||||
|
(37, _migration_37),
|
||||||
|
(38, _migration_38),
|
||||||
|
(39, _migration_39),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+51
-2
@@ -178,6 +178,19 @@ class Frame(Base):
|
|||||||
# 0.0-1.0, see image_pipeline._quantize -- 1.0 matches this project's
|
# 0.0-1.0, see image_pipeline._quantize -- 1.0 matches this project's
|
||||||
# original always-on full-strength Floyd-Steinberg dithering.
|
# original always-on full-strength Floyd-Steinberg dithering.
|
||||||
dither_strength: Mapped[float] = mapped_column(Float, default=1.0)
|
dither_strength: Mapped[float] = mapped_column(Float, default=1.0)
|
||||||
|
# Same shape as palette_rgb/dither_strength above, but scoped to only
|
||||||
|
# the photos widget (widgets/photos.py quantizes against these itself,
|
||||||
|
# before returning -- see its own docstring) -- lets a frame tune the
|
||||||
|
# rest of its widgets' palette/dithering (e.g. a "modern" HTML-
|
||||||
|
# rendered dashboard look) independently of what actually looks best
|
||||||
|
# for real photographs. NULL/1.0 = same defaults as the main fields.
|
||||||
|
photo_palette_rgb: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
|
||||||
|
photo_dither_strength: Mapped[float] = mapped_column(Float, default=1.0)
|
||||||
|
# Curated visual theme for "modern" (HTML/CSS) style widgets -- see
|
||||||
|
# theme_tokens.THEMES. Frame-level (like palette_rgb/dither_strength
|
||||||
|
# above), not per-widget, since a theme is "how this frame looks."
|
||||||
|
# Widgets rendered in classic (PIL) style ignore this entirely.
|
||||||
|
theme: Mapped[str] = mapped_column(String, default="classic")
|
||||||
|
|
||||||
# -- calendar mode (see calendar_feed.py, calendar_render.py,
|
# -- calendar mode (see calendar_feed.py, calendar_render.py,
|
||||||
# routers/device.py's RENDERERS["calendar"]) --
|
# routers/device.py's RENDERERS["calendar"]) --
|
||||||
@@ -328,6 +341,16 @@ class Frame(Base):
|
|||||||
# starts over from the first one, same as an unset value.
|
# starts over from the first one, same as an unset value.
|
||||||
last_cycled_layout_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
last_cycled_layout_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
|
||||||
|
# -- "now displaying" (see routers/device.py's _record_last_displayed,
|
||||||
|
# api_frames.py's /now-displaying endpoint) -- exactly what the last
|
||||||
|
# device-facing render (/frame/image, /frame/advance, /frame/back, or
|
||||||
|
# a global hold action) actually sent, as an upright PNG, so the web
|
||||||
|
# UI's header preview can show it frozen alongside a live "up next"
|
||||||
|
# re-render instead of conflating the two. NULL until a real device
|
||||||
|
# has fetched at least once.
|
||||||
|
last_displayed_image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True, default=None)
|
||||||
|
last_displayed_at: Mapped[float] = mapped_column(Float, default=0.0)
|
||||||
|
|
||||||
# -- stats (flattened from the old nested FrameStats) --
|
# -- stats (flattened from the old nested FrameStats) --
|
||||||
stats_first_seen: Mapped[float] = mapped_column(Float, default=0.0)
|
stats_first_seen: Mapped[float] = mapped_column(Float, default=0.0)
|
||||||
stats_device_wakes: Mapped[int] = mapped_column(Integer, default=0)
|
stats_device_wakes: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
@@ -532,6 +555,10 @@ class CalendarWidgetConfig(Base):
|
|||||||
week_days: Mapped[int] = mapped_column(Integer, default=7)
|
week_days: Mapped[int] = mapped_column(Integer, default=7)
|
||||||
week_layout: Mapped[str] = mapped_column(String, default="horizontal")
|
week_layout: Mapped[str] = mapped_column(String, default="horizontal")
|
||||||
week_start_offset: Mapped[int] = mapped_column(Integer, default=0)
|
week_start_offset: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
# classic (calendar_render.py) vs modern (app/calendar_html_render.py,
|
||||||
|
# agenda mode only so far -- see that module's docstring) -- see
|
||||||
|
# widgets/calendar.py's render().
|
||||||
|
render_style: Mapped[str] = mapped_column(String, default="classic")
|
||||||
|
|
||||||
|
|
||||||
class TaskWidgetConfig(Base):
|
class TaskWidgetConfig(Base):
|
||||||
@@ -570,6 +597,9 @@ class TaskWidgetConfig(Base):
|
|||||||
# outstanding ones -- off by default, same "opt into more" posture
|
# outstanding ones -- off by default, same "opt into more" posture
|
||||||
# as calendar_weather_enabled.
|
# as calendar_weather_enabled.
|
||||||
show_completed: Mapped[bool] = mapped_column(Boolean, default=False)
|
show_completed: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
# classic (hand-drawn PIL, calendar_render._build_tasks) vs modern
|
||||||
|
# (app/html_render.py) -- see widgets/tasks.py's render().
|
||||||
|
render_style: Mapped[str] = mapped_column(String, default="classic")
|
||||||
|
|
||||||
|
|
||||||
class WhiteboardWidgetConfig(Base):
|
class WhiteboardWidgetConfig(Base):
|
||||||
@@ -583,6 +613,10 @@ class WhiteboardWidgetConfig(Base):
|
|||||||
url: Mapped[str] = mapped_column(String, default="")
|
url: Mapped[str] = mapped_column(String, default="")
|
||||||
checked_at: Mapped[float] = mapped_column(Float, default=0.0)
|
checked_at: Mapped[float] = mapped_column(Float, default=0.0)
|
||||||
cached_image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
|
cached_image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
|
||||||
|
# classic (no chrome, unchanged) vs modern (app/html_render.py's
|
||||||
|
# rounded-corner shadowed card) -- see widgets/whiteboard.py's
|
||||||
|
# render().
|
||||||
|
render_style: Mapped[str] = mapped_column(String, default="classic")
|
||||||
|
|
||||||
|
|
||||||
class WeatherWidgetConfig(Base):
|
class WeatherWidgetConfig(Base):
|
||||||
@@ -597,13 +631,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
|
||||||
@@ -652,6 +692,9 @@ class TextWidgetConfig(Base):
|
|||||||
font_family: Mapped[str] = mapped_column(String, default="sans")
|
font_family: Mapped[str] = mapped_column(String, default="sans")
|
||||||
align: Mapped[str] = mapped_column(String, default="left") # "left" | "center" | "right"
|
align: Mapped[str] = mapped_column(String, default="left") # "left" | "center" | "right"
|
||||||
background_color: Mapped[str] = mapped_column(String, default="#ffffff")
|
background_color: Mapped[str] = mapped_column(String, default="#ffffff")
|
||||||
|
# classic (hand-drawn PIL) vs modern (app/html_render.py) -- see
|
||||||
|
# widgets/text.py's render()/render_preview_png().
|
||||||
|
render_style: Mapped[str] = mapped_column(String, default="classic")
|
||||||
|
|
||||||
|
|
||||||
class StaticWidgetConfig(Base):
|
class StaticWidgetConfig(Base):
|
||||||
@@ -673,6 +716,10 @@ class StaticWidgetConfig(Base):
|
|||||||
# minus crop_faces -- no face detection for an uploaded image (see
|
# minus crop_faces -- no face detection for an uploaded image (see
|
||||||
# image_pipeline.STATIC_DISPLAY_MODES).
|
# image_pipeline.STATIC_DISPLAY_MODES).
|
||||||
display_mode: Mapped[str] = mapped_column(String, default="crop_fill")
|
display_mode: Mapped[str] = mapped_column(String, default="crop_fill")
|
||||||
|
# classic (no chrome, unchanged) vs modern (app/html_render.py's
|
||||||
|
# rounded-corner shadowed card) -- see widgets/static_image.py's
|
||||||
|
# render().
|
||||||
|
render_style: Mapped[str] = mapped_column(String, default="classic")
|
||||||
|
|
||||||
|
|
||||||
class BatteryWidgetConfig(Base):
|
class BatteryWidgetConfig(Base):
|
||||||
@@ -683,12 +730,14 @@ class BatteryWidgetConfig(Base):
|
|||||||
of anything the widget itself fetches or the user authors. `mode`
|
of anything the widget itself fetches or the user authors. `mode`
|
||||||
"compact" is icon + percent only; "detailed" (default) adds the
|
"compact" is icon + percent only; "detailed" (default) adds the
|
||||||
routers.common.battery_estimate_s time-remaining estimate and the
|
routers.common.battery_estimate_s time-remaining estimate and the
|
||||||
last report's age."""
|
last report's age. `render_style` picks classic (hand-drawn PIL) vs
|
||||||
|
modern (app/html_render.py) -- see widgets/battery.py's render()."""
|
||||||
|
|
||||||
__tablename__ = "battery_widget_configs"
|
__tablename__ = "battery_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="detailed") # compact | detailed
|
mode: Mapped[str] = mapped_column(String, default="detailed") # compact | detailed
|
||||||
|
render_style: Mapped[str] = mapped_column(String, default="classic") # classic | modern
|
||||||
|
|
||||||
|
|
||||||
# widget_type -> its per-type extension table, keyed by widget_id. Used
|
# widget_type -> its per-type extension table, keyed by widget_id. Used
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -25,7 +25,7 @@ from fastapi.responses import Response
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from .. import gitea_releases, grid, quiet_hours
|
from .. import gitea_releases, grid, quiet_hours, theme_tokens
|
||||||
from ..auth import require_frame_control, require_frame_view, require_user_api
|
from ..auth import require_frame_control, require_frame_view, require_user_api
|
||||||
from ..db import frame_locked, get_db
|
from ..db import frame_locked, get_db
|
||||||
from ..global_actions import GLOBAL_ACTIONS
|
from ..global_actions import GLOBAL_ACTIONS
|
||||||
@@ -105,6 +105,10 @@ def api_config_save(
|
|||||||
color_boost: float | None = Form(None),
|
color_boost: float | None = Form(None),
|
||||||
contrast_boost: float | None = Form(None),
|
contrast_boost: float | None = Form(None),
|
||||||
dither_strength: float | None = Form(None),
|
dither_strength: float | None = Form(None),
|
||||||
|
photo_palette: list[str] | None = Form(None),
|
||||||
|
photo_palette_reset: bool | None = Form(None),
|
||||||
|
photo_dither_strength: float | None = Form(None),
|
||||||
|
theme: str | None = Form(None),
|
||||||
hold_duration_ms: int | None = Form(None),
|
hold_duration_ms: int | None = Form(None),
|
||||||
next_hold_action: str | None = Form(None),
|
next_hold_action: str | None = Form(None),
|
||||||
back_hold_action: str | None = Form(None),
|
back_hold_action: str | None = Form(None),
|
||||||
@@ -182,6 +186,19 @@ def api_config_save(
|
|||||||
cfg.contrast_boost = max(0.0, min(2.0, contrast_boost))
|
cfg.contrast_boost = max(0.0, min(2.0, contrast_boost))
|
||||||
if dither_strength is not None:
|
if dither_strength is not None:
|
||||||
cfg.dither_strength = max(0.0, min(1.0, dither_strength))
|
cfg.dither_strength = max(0.0, min(1.0, dither_strength))
|
||||||
|
if photo_palette_reset:
|
||||||
|
cfg.photo_palette_rgb = None
|
||||||
|
elif photo_palette is not None:
|
||||||
|
if len(photo_palette) != len(PALETTE_LABELS):
|
||||||
|
raise HTTPException(400, f"Expected {len(PALETTE_LABELS)} palette colors, got {len(photo_palette)}")
|
||||||
|
parsed = [hex_to_rgb(h) for h in photo_palette]
|
||||||
|
if any(rgb is None for rgb in parsed):
|
||||||
|
raise HTTPException(400, "Palette colors must be #rrggbb hex values")
|
||||||
|
cfg.photo_palette_rgb = [list(rgb) for rgb in parsed]
|
||||||
|
if photo_dither_strength is not None:
|
||||||
|
cfg.photo_dither_strength = max(0.0, min(1.0, photo_dither_strength))
|
||||||
|
if theme is not None:
|
||||||
|
cfg.theme = theme if theme in theme_tokens.THEMES else "classic"
|
||||||
if hold_duration_ms is not None:
|
if hold_duration_ms is not None:
|
||||||
cfg.hold_duration_ms = max(MIN_HOLD_DURATION_MS, min(MAX_HOLD_DURATION_MS, hold_duration_ms))
|
cfg.hold_duration_ms = max(MIN_HOLD_DURATION_MS, min(MAX_HOLD_DURATION_MS, hold_duration_ms))
|
||||||
if next_hold_action is not None:
|
if next_hold_action is not None:
|
||||||
@@ -244,6 +261,14 @@ def api_status(
|
|||||||
"device": {
|
"device": {
|
||||||
"last_seen": frame.last_seen or None,
|
"last_seen": frame.last_seen or None,
|
||||||
"overdue": bool(frame.last_seen and now - frame.last_seen > overdue_gap),
|
"overdue": bool(frame.last_seen and now - frame.last_seen > overdue_gap),
|
||||||
|
# When the device is next expected to check in, per the same
|
||||||
|
# sleep duration frame_config() actually hands it (see
|
||||||
|
# device.py's /frame/config) -- not the raw overdue_gap above,
|
||||||
|
# which is deliberately generous (OVERDUE_FACTOR) to avoid
|
||||||
|
# false alarms during quiet hours rather than a best guess.
|
||||||
|
"expected_next_checkin": (
|
||||||
|
frame.last_seen + quiet_hours.effective_refresh_interval_s(frame) if frame.last_seen else None
|
||||||
|
),
|
||||||
"firmware_version": frame.device_firmware_version or None,
|
"firmware_version": frame.device_firmware_version or None,
|
||||||
"firmware_available": frame.firmware_available_version or None,
|
"firmware_available": frame.firmware_available_version or None,
|
||||||
"battery": (
|
"battery": (
|
||||||
@@ -271,6 +296,25 @@ def api_frame_preview(
|
|||||||
return Response(content=png, media_type="image/png")
|
return Response(content=png, media_type="image/png")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/frames/{frame_id}/now-displaying")
|
||||||
|
def api_frame_now_displaying(frame: Frame = Depends(require_frame_view)):
|
||||||
|
"""Exactly what was last actually sent to this frame's device (see
|
||||||
|
routers/device.py's _record_last_displayed) -- the frozen "now
|
||||||
|
displaying" half of the header preview pair, as opposed to /preview's
|
||||||
|
always-live "up next" re-render. 404 (not a placeholder image) until
|
||||||
|
the device has fetched at least once, so the web UI can show its own
|
||||||
|
empty state instead of a broken image. X-Displayed-At carries the
|
||||||
|
capture time (unix seconds) for a "N ago" label -- a header, not the
|
||||||
|
body, since the body is the raw PNG bytes."""
|
||||||
|
if frame.last_displayed_image is None:
|
||||||
|
raise HTTPException(404, "This frame hasn't displayed anything yet")
|
||||||
|
return Response(
|
||||||
|
content=frame.last_displayed_image,
|
||||||
|
media_type="image/png",
|
||||||
|
headers={"X-Displayed-At": str(frame.last_displayed_at)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/frames/{frame_id}/battery-log")
|
@router.get("/api/frames/{frame_id}/battery-log")
|
||||||
def api_battery_log(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
|
def api_battery_log(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
|
||||||
rows = db.execute(
|
rows = db.execute(
|
||||||
@@ -384,6 +428,7 @@ def api_firmware_check(
|
|||||||
"board": frame.device_board_variant or None,
|
"board": frame.device_board_variant or None,
|
||||||
"latest_version": frame.firmware_gitea_latest_version or None,
|
"latest_version": frame.firmware_gitea_latest_version or None,
|
||||||
"staged_version": frame.firmware_available_version or None,
|
"staged_version": frame.firmware_available_version or None,
|
||||||
|
"running_version": frame.device_firmware_version or None,
|
||||||
"update_available": update_available,
|
"update_available": update_available,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,13 +56,17 @@ LAYOUT_CONFIG_FIELDS: dict[str, tuple[str, ...]] = {
|
|||||||
"photos": ("album_id", "order", "display_mode", "queue_target_len"),
|
"photos": ("album_id", "order", "display_mode", "queue_target_len"),
|
||||||
"calendar": (
|
"calendar": (
|
||||||
"view", "week_start", "weather_enabled", "weather_units", "weather_cities",
|
"view", "week_start", "weather_enabled", "weather_units", "weather_cities",
|
||||||
"week_days", "week_layout", "week_start_offset",
|
"week_days", "week_layout", "week_start_offset", "render_style",
|
||||||
|
),
|
||||||
|
"tasks": ("name", "show_completed", "render_style"),
|
||||||
|
"static": ("display_mode", "original_filename", "render_style"),
|
||||||
|
"text": ("content", "font_size", "font_family", "align", "background_color", "render_style"),
|
||||||
|
"whiteboard": ("user_id", "url", "render_style"),
|
||||||
|
"battery": ("mode", "render_style"),
|
||||||
|
"weather": (
|
||||||
|
"mode", "provider", "units", "city_label", "city_latitude", "city_longitude",
|
||||||
|
"hourly_interval_hours", "daily_days", "cities", "render_style",
|
||||||
),
|
),
|
||||||
"tasks": ("name", "show_completed"),
|
|
||||||
"static": ("display_mode", "original_filename"),
|
|
||||||
"text": ("content", "font_size", "font_family", "align", "background_color"),
|
|
||||||
"whiteboard": ("user_id", "url"),
|
|
||||||
"battery": ("mode",),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# widget_type -> (FrameCalendar|FrameTaskList model, SavedLayoutSource.kind)
|
# widget_type -> (FrameCalendar|FrameTaskList model, SavedLayoutSource.kind)
|
||||||
|
|||||||
@@ -35,11 +35,16 @@ from ..image_pipeline import (
|
|||||||
DEFAULT_STATIC_DISPLAY_MODE,
|
DEFAULT_STATIC_DISPLAY_MODE,
|
||||||
DISPLAY_MODES,
|
DISPLAY_MODES,
|
||||||
hex_to_rgb,
|
hex_to_rgb,
|
||||||
|
logical_render_size,
|
||||||
MAX_BORDER_THICKNESS,
|
MAX_BORDER_THICKNESS,
|
||||||
MIN_BORDER_THICKNESS,
|
MIN_BORDER_THICKNESS,
|
||||||
PALETTE_LABELS,
|
PALETTE_LABELS,
|
||||||
STATIC_DISPLAY_MODES,
|
STATIC_DISPLAY_MODES,
|
||||||
render_preview_png,
|
render_preview_png,
|
||||||
|
compose_into,
|
||||||
|
_enhance,
|
||||||
|
_png_bytes,
|
||||||
|
_quantize,
|
||||||
)
|
)
|
||||||
from ..image_upload import decode_upload
|
from ..image_upload import decode_upload
|
||||||
from ..models import (
|
from ..models import (
|
||||||
@@ -317,9 +322,12 @@ def api_widget_config_save(
|
|||||||
album_id: str | None = Form(None),
|
album_id: str | None = Form(None),
|
||||||
order: str | None = Form(None),
|
order: str | None = Form(None),
|
||||||
display_mode: str | None = Form(None),
|
display_mode: str | None = Form(None),
|
||||||
|
static_render_style: str | None = Form(None),
|
||||||
|
whiteboard_render_style: str | None = Form(None),
|
||||||
queue_target_len: int | None = Form(None),
|
queue_target_len: int | None = Form(None),
|
||||||
# calendar
|
# calendar
|
||||||
calendar_view: str | None = Form(None),
|
calendar_view: str | None = Form(None),
|
||||||
|
calendar_render_style: str | None = Form(None),
|
||||||
calendar_week_start: int | None = Form(None),
|
calendar_week_start: int | None = Form(None),
|
||||||
calendar_week_days: int | None = Form(None),
|
calendar_week_days: int | None = Form(None),
|
||||||
calendar_week_layout: str | None = Form(None),
|
calendar_week_layout: str | None = Form(None),
|
||||||
@@ -329,10 +337,12 @@ def api_widget_config_save(
|
|||||||
# tasks
|
# tasks
|
||||||
tasks_name: str | None = Form(None),
|
tasks_name: str | None = Form(None),
|
||||||
tasks_show_completed: bool | None = Form(None),
|
tasks_show_completed: bool | None = Form(None),
|
||||||
|
tasks_render_style: str | None = Form(None),
|
||||||
# text
|
# text
|
||||||
text_html: str | None = Form(None),
|
text_html: str | None = Form(None),
|
||||||
text_font_size: int | None = Form(None),
|
text_font_size: int | None = Form(None),
|
||||||
text_font_family: str | None = Form(None),
|
text_font_family: str | None = Form(None),
|
||||||
|
text_render_style: str | None = Form(None),
|
||||||
text_align: str | None = Form(None),
|
text_align: str | None = Form(None),
|
||||||
text_background_color: str | None = Form(None),
|
text_background_color: str | None = Form(None),
|
||||||
# weather
|
# weather
|
||||||
@@ -341,8 +351,10 @@ 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),
|
||||||
|
battery_render_style: str | None = Form(None),
|
||||||
):
|
):
|
||||||
"""Every field optional -- same partial-update, form-urlencoded
|
"""Every field optional -- same partial-update, form-urlencoded
|
||||||
convention as the old frame-level api_config_save, now scoped to one
|
convention as the old frame-level api_config_save, now scoped to one
|
||||||
@@ -405,6 +417,8 @@ def api_widget_config_save(
|
|||||||
# new unit label.
|
# new unit label.
|
||||||
ccfg.weather_checked_at = 0.0
|
ccfg.weather_checked_at = 0.0
|
||||||
ccfg.weather_units = calendar_weather_units
|
ccfg.weather_units = calendar_weather_units
|
||||||
|
if calendar_render_style is not None and calendar_render_style in ("classic", "modern"):
|
||||||
|
ccfg.render_style = calendar_render_style
|
||||||
elif widget.widget_type == "tasks":
|
elif widget.widget_type == "tasks":
|
||||||
with widget_locked(db, frame.id, widget.id) as (_, _, tcfg):
|
with widget_locked(db, frame.id, widget.id) as (_, _, tcfg):
|
||||||
if tasks_name is not None:
|
if tasks_name is not None:
|
||||||
@@ -415,10 +429,22 @@ def api_widget_config_save(
|
|||||||
if tasks_show_completed is not None and tasks_show_completed != tcfg.show_completed:
|
if tasks_show_completed is not None and tasks_show_completed != tcfg.show_completed:
|
||||||
tcfg.show_completed = tasks_show_completed
|
tcfg.show_completed = tasks_show_completed
|
||||||
tcfg.checked_at = 0.0 # pick up the change promptly
|
tcfg.checked_at = 0.0 # pick up the change promptly
|
||||||
|
if tasks_render_style is not None and tasks_render_style in ("classic", "modern"):
|
||||||
|
tcfg.render_style = tasks_render_style
|
||||||
elif widget.widget_type == "static":
|
elif widget.widget_type == "static":
|
||||||
with widget_locked(db, frame.id, widget.id) as (_, _, scfg):
|
with widget_locked(db, frame.id, widget.id) as (_, _, scfg):
|
||||||
if display_mode is not None:
|
if display_mode is not None:
|
||||||
scfg.display_mode = display_mode if display_mode in STATIC_DISPLAY_MODES else DEFAULT_STATIC_DISPLAY_MODE
|
scfg.display_mode = display_mode if display_mode in STATIC_DISPLAY_MODES else DEFAULT_STATIC_DISPLAY_MODE
|
||||||
|
if static_render_style is not None and static_render_style in ("classic", "modern"):
|
||||||
|
scfg.render_style = static_render_style
|
||||||
|
elif widget.widget_type == "whiteboard":
|
||||||
|
# user_id/url go through the dedicated /whiteboard-source endpoint
|
||||||
|
# (owner-only, JSON body) -- render_style is the one setting this
|
||||||
|
# widget type takes through the shared /config form, same as
|
||||||
|
# every other widget type's own render_style.
|
||||||
|
with widget_locked(db, frame.id, widget.id) as (_, _, wbcfg):
|
||||||
|
if whiteboard_render_style is not None and whiteboard_render_style in ("classic", "modern"):
|
||||||
|
wbcfg.render_style = whiteboard_render_style
|
||||||
elif widget.widget_type == "text":
|
elif widget.widget_type == "text":
|
||||||
with widget_locked(db, frame.id, widget.id) as (_, _, xcfg):
|
with widget_locked(db, frame.id, widget.id) as (_, _, xcfg):
|
||||||
if text_html is not None:
|
if text_html is not None:
|
||||||
@@ -439,6 +465,8 @@ def api_widget_config_save(
|
|||||||
xcfg.background_color = (
|
xcfg.background_color = (
|
||||||
text_background_color if hex_to_rgb(text_background_color) else "#ffffff"
|
text_background_color if hex_to_rgb(text_background_color) else "#ffffff"
|
||||||
)
|
)
|
||||||
|
if text_render_style is not None and text_render_style in ("classic", "modern"):
|
||||||
|
xcfg.render_style = text_render_style
|
||||||
elif widget.widget_type == "weather":
|
elif widget.widget_type == "weather":
|
||||||
with widget_locked(db, frame.id, widget.id) as (_, _, wcfg):
|
with widget_locked(db, frame.id, widget.id) as (_, _, wcfg):
|
||||||
if weather_mode is not None and weather_mode in ("current", "hourly", "daily", "multi_city"):
|
if weather_mode is not None and weather_mode in ("current", "hourly", "daily", "multi_city"):
|
||||||
@@ -469,10 +497,14 @@ 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:
|
||||||
bcfg.mode = battery_mode if battery_mode in ("compact", "detailed") else "detailed"
|
bcfg.mode = battery_mode if battery_mode in ("compact", "detailed") else "detailed"
|
||||||
|
if battery_render_style is not None and battery_render_style in ("classic", "modern"):
|
||||||
|
bcfg.render_style = battery_render_style
|
||||||
with frame_locked(db, frame.id) as cfg:
|
with frame_locked(db, frame.id) as cfg:
|
||||||
cfg.stats_config_saves += 1
|
cfg.stats_config_saves += 1
|
||||||
return {"status": "saved"}
|
return {"status": "saved"}
|
||||||
@@ -833,6 +865,22 @@ def api_widget_preview_calendar(
|
|||||||
ccfg = db.get(CalendarWidgetConfig, widget.id)
|
ccfg = db.get(CalendarWidgetConfig, widget.id)
|
||||||
events, summary = get_or_refresh_calendar_events_for_widget(db, frame, widget)
|
events, summary = get_or_refresh_calendar_events_for_widget(db, frame, widget)
|
||||||
weather_cities = get_or_refresh_weather_for_widget(db, frame, widget) if ccfg.weather_enabled else None
|
weather_cities = get_or_refresh_weather_for_widget(db, frame, widget) if ccfg.weather_enabled else None
|
||||||
|
|
||||||
|
target_w, target_h = logical_render_size(frame.orientation)
|
||||||
|
if ccfg.render_style == "modern":
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from .. import calendar_html_render
|
||||||
|
|
||||||
|
tz = ZoneInfo(frame.timezone) if frame.timezone else ZoneInfo("UTC")
|
||||||
|
img = calendar_html_render.build(
|
||||||
|
events, ccfg.view, ccfg.browse_offset, target_w, target_h, tz, ccfg.week_start, frame.palette_rgb,
|
||||||
|
weather_cities, ccfg.weather_units, ccfg.week_days, ccfg.week_layout, ccfg.week_start_offset,
|
||||||
|
frame.theme,
|
||||||
|
)
|
||||||
|
quantized = _quantize(img, frame.palette_rgb, dither_strength=1.0)
|
||||||
|
png = _png_bytes(quantized)
|
||||||
|
else:
|
||||||
png = calendar_render.render_calendar_preview_png(
|
png = calendar_render.render_calendar_preview_png(
|
||||||
events, view=ccfg.view, browse_offset=ccfg.browse_offset, orientation=frame.orientation,
|
events, view=ccfg.view, browse_offset=ccfg.browse_offset, orientation=frame.orientation,
|
||||||
palette_rgb=frame.palette_rgb, timezone=frame.timezone, fetch_summary=summary,
|
palette_rgb=frame.palette_rgb, timezone=frame.timezone, fetch_summary=summary,
|
||||||
@@ -859,8 +907,17 @@ def api_widget_preview_tasks(
|
|||||||
raise HTTPException(400, "No task lists included on this widget yet")
|
raise HTTPException(400, "No task lists included on this widget yet")
|
||||||
tcfg = db.get(TaskWidgetConfig, widget.id)
|
tcfg = db.get(TaskWidgetConfig, widget.id)
|
||||||
tasks = get_or_refresh_tasks_for_widget(db, frame, widget)
|
tasks = get_or_refresh_tasks_for_widget(db, frame, widget)
|
||||||
|
title = tcfg.name or "Tasks"
|
||||||
|
if tcfg.render_style == "modern":
|
||||||
|
from .. import html_render
|
||||||
|
|
||||||
|
target_w, target_h = logical_render_size(frame.orientation)
|
||||||
|
img = html_render.build_tasks(tasks, target_w, target_h, frame.palette_rgb, title, frame.theme)
|
||||||
|
quantized = _quantize(img, frame.palette_rgb, dither_strength=1.0)
|
||||||
|
png = _png_bytes(quantized)
|
||||||
|
else:
|
||||||
png = calendar_render.render_tasks_preview_png(tasks, orientation=frame.orientation,
|
png = calendar_render.render_tasks_preview_png(tasks, orientation=frame.orientation,
|
||||||
palette_rgb=frame.palette_rgb, title=tcfg.name or "Tasks")
|
palette_rgb=frame.palette_rgb, title=title)
|
||||||
return Response(content=png, media_type="image/png")
|
return Response(content=png, media_type="image/png")
|
||||||
|
|
||||||
|
|
||||||
@@ -1108,6 +1165,15 @@ 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")
|
||||||
|
if wcfg.render_style == "modern" and wcfg.mode in ("current", "daily"):
|
||||||
|
# Same local-import reasoning as widgets/weather.py's render().
|
||||||
|
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 "", theme_name=frame.theme,
|
||||||
|
)
|
||||||
|
else:
|
||||||
png = weather_render.render_weather_preview_png(
|
png = weather_render.render_weather_preview_png(
|
||||||
wcfg.mode, data, orientation=frame.orientation, palette_rgb=frame.palette_rgb, units=wcfg.units,
|
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,
|
city_label=wcfg.city_label or "", interval_hours=wcfg.hourly_interval_hours,
|
||||||
@@ -1158,6 +1224,17 @@ def api_widget_preview_static(
|
|||||||
if not scfg.image:
|
if not scfg.image:
|
||||||
raise HTTPException(400, "No image uploaded to this widget yet")
|
raise HTTPException(400, "No image uploaded to this widget yet")
|
||||||
source = Image.open(io.BytesIO(scfg.image)).convert("RGB")
|
source = Image.open(io.BytesIO(scfg.image)).convert("RGB")
|
||||||
|
if scfg.render_style == "modern":
|
||||||
|
from .. import html_render
|
||||||
|
|
||||||
|
target_w, target_h = logical_render_size(frame.orientation)
|
||||||
|
composed = compose_into(source, faces=None, target_w=target_w, target_h=target_h,
|
||||||
|
display_mode=scfg.display_mode)
|
||||||
|
fitted = _enhance(composed, frame.color_boost, frame.contrast_boost)
|
||||||
|
img = html_render.build_framed_image(fitted, target_w, target_h, frame.palette_rgb, frame.theme, "static")
|
||||||
|
quantized = _quantize(img, frame.palette_rgb, dither_strength=1.0)
|
||||||
|
png = _png_bytes(quantized)
|
||||||
|
else:
|
||||||
png = render_preview_png(
|
png = render_preview_png(
|
||||||
source, faces=None, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
|
source, faces=None, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
|
||||||
display_mode=scfg.display_mode, color_boost=frame.color_boost,
|
display_mode=scfg.display_mode, color_boost=frame.color_boost,
|
||||||
@@ -1181,7 +1258,8 @@ def api_widget_preview_text(
|
|||||||
xcfg = db.get(TextWidgetConfig, widget.id)
|
xcfg = db.get(TextWidgetConfig, widget.id)
|
||||||
if not has_text(xcfg.content):
|
if not has_text(xcfg.content):
|
||||||
raise HTTPException(400, "No text authored on this widget yet")
|
raise HTTPException(400, "No text authored on this widget yet")
|
||||||
png = text_widget.render_preview_png(xcfg, orientation=frame.orientation, palette_rgb=frame.palette_rgb)
|
png = text_widget.render_preview_png(xcfg, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
|
||||||
|
theme_name=frame.theme)
|
||||||
return Response(content=png, media_type="image/png")
|
return Response(content=png, media_type="image/png")
|
||||||
|
|
||||||
|
|
||||||
@@ -1297,6 +1375,17 @@ def api_widget_preview_whiteboard(
|
|||||||
raise HTTPException(400, "No whiteboard configured on this widget yet")
|
raise HTTPException(400, "No whiteboard configured on this widget yet")
|
||||||
raise HTTPException(502, "Could not fetch/render the whiteboard yet -- check the URL and credentials")
|
raise HTTPException(502, "Could not fetch/render the whiteboard yet -- check the URL and credentials")
|
||||||
source = Image.open(io.BytesIO(png_bytes)).convert("RGB")
|
source = Image.open(io.BytesIO(png_bytes)).convert("RGB")
|
||||||
|
if wcfg.render_style == "modern":
|
||||||
|
from .. import html_render
|
||||||
|
|
||||||
|
target_w, target_h = logical_render_size(frame.orientation)
|
||||||
|
composed = compose_into(source, faces=None, target_w=target_w, target_h=target_h,
|
||||||
|
display_mode="letterbox")
|
||||||
|
img = html_render.build_framed_image(composed, target_w, target_h, frame.palette_rgb, frame.theme,
|
||||||
|
"whiteboard")
|
||||||
|
quantized = _quantize(img, frame.palette_rgb, dither_strength=1.0)
|
||||||
|
png = _png_bytes(quantized)
|
||||||
|
else:
|
||||||
png = render_preview_png(
|
png = render_preview_png(
|
||||||
source, faces=None, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
|
source, faces=None, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
|
||||||
display_mode="letterbox",
|
display_mode="letterbox",
|
||||||
|
|||||||
@@ -59,6 +59,12 @@ MIN_ESTIMATE_SAMPLES = 5 # discharge steps needed before trusting the average
|
|||||||
# single noisy reading needs rejecting at the per-wake-drop level, not
|
# single noisy reading needs rejecting at the per-wake-drop level, not
|
||||||
# just at the recharge-detection level.
|
# just at the recharge-detection level.
|
||||||
OUTLIER_MODIFIED_Z_THRESHOLD = 3.5
|
OUTLIER_MODIFIED_Z_THRESHOLD = 3.5
|
||||||
|
# Readings considered on each side of a given reading when
|
||||||
|
# _smooth_percents looks for local outliers. Needs to be at least half
|
||||||
|
# the length of the longest bad-reading burst a noisy divider produces
|
||||||
|
# (observed up to ~4 consecutive corrupted reports on one frame) so the
|
||||||
|
# good neighbors still outnumber the bad ones in the window.
|
||||||
|
BATTERY_SMOOTHING_WINDOW = 4
|
||||||
|
|
||||||
# "Overdue" threshold multiplier: the device should check in roughly every
|
# "Overdue" threshold multiplier: the device should check in roughly every
|
||||||
# refresh_interval_s; give it half again as long before flagging it.
|
# refresh_interval_s; give it half again as long before flagging it.
|
||||||
@@ -179,6 +185,50 @@ def _reject_outlier_drops(steps: list[tuple[int, float]]) -> list[tuple[int, flo
|
|||||||
return kept or steps # never filter down to nothing
|
return kept or steps # never filter down to nothing
|
||||||
|
|
||||||
|
|
||||||
|
def _smooth_percents(percents: list[int]) -> list[float]:
|
||||||
|
"""Replaces any reading that's a wild outlier against its own local
|
||||||
|
neighborhood with that neighborhood's median, before per-wake drop
|
||||||
|
steps are ever built from the series.
|
||||||
|
|
||||||
|
_reject_outlier_drops (above) only catches a bad reading by how much
|
||||||
|
it distorts the *steps* immediately on either side of it -- which is
|
||||||
|
exactly what one isolated glitch does, but a 1M-ohm divider (see
|
||||||
|
firmware/main/battery.c) doesn't always misfire in isolation: several
|
||||||
|
consecutive reports can drift or glitch together (a multi-minute
|
||||||
|
crawl from 68 up into the high 70s with nothing charging, or a run of
|
||||||
|
several ~40 reports spliced into an otherwise flat ~53 run). A step
|
||||||
|
computed *between* two bad readings in the same burst looks like an
|
||||||
|
ordinary small change, not an outlier, so it sails through
|
||||||
|
_reject_outlier_drops untouched.
|
||||||
|
|
||||||
|
A Hampel identifier catches that instead: each reading is compared to
|
||||||
|
the median of its own local window (not the whole series), using the
|
||||||
|
same MAD-based modified z-score as _reject_outlier_drops so this
|
||||||
|
adapts to how noisy a given frame's sensor actually is rather than a
|
||||||
|
fixed percent-point cutoff. A window of BATTERY_SMOOTHING_WINDOW
|
||||||
|
reports on each side tolerates a bad burst up to that long while
|
||||||
|
still being outvoted by the surrounding good readings."""
|
||||||
|
n = len(percents)
|
||||||
|
smoothed = list(percents)
|
||||||
|
for i in range(n):
|
||||||
|
lo = max(0, i - BATTERY_SMOOTHING_WINDOW)
|
||||||
|
hi = min(n, i + BATTERY_SMOOTHING_WINDOW + 1)
|
||||||
|
neighborhood = percents[lo:hi]
|
||||||
|
median = statistics.median(neighborhood)
|
||||||
|
abs_devs = [abs(v - median) for v in neighborhood]
|
||||||
|
# Unlike _reject_outlier_drops, no mean-of-abs-devs fallback here:
|
||||||
|
# a burst can be a big enough share of this small a window that
|
||||||
|
# the mean itself gets dragged up by the very values being
|
||||||
|
# tested, hiding them. A flat 1-percentage-point floor -- this
|
||||||
|
# project's smallest real unit of noise -- keeps the test from
|
||||||
|
# dividing by zero without being skewed by the burst it's
|
||||||
|
# checking.
|
||||||
|
mad = statistics.median(abs_devs) or 1
|
||||||
|
if abs(0.6745 * (percents[i] - median) / mad) > OUTLIER_MODIFIED_Z_THRESHOLD:
|
||||||
|
smoothed[i] = median
|
||||||
|
return smoothed
|
||||||
|
|
||||||
|
|
||||||
def battery_estimate_s(frame: Frame, db: Session) -> int | None:
|
def battery_estimate_s(frame: Frame, db: Session) -> int | None:
|
||||||
"""Remaining-time estimate from a recency-weighted average of the
|
"""Remaining-time estimate from a recency-weighted average of the
|
||||||
*per-wake* percent drop, over the last BATTERY_ESTIMATE_SAMPLE_COUNT
|
*per-wake* percent drop, over the last BATTERY_ESTIMATE_SAMPLE_COUNT
|
||||||
@@ -189,18 +239,21 @@ def battery_estimate_s(frame: Frame, db: Session) -> int | None:
|
|||||||
|
|
||||||
Consecutive reports are assumed to be consecutive wakes (firmware
|
Consecutive reports are assumed to be consecutive wakes (firmware
|
||||||
reports battery on every wake while on battery), so each step's
|
reports battery on every wake while on battery), so each step's
|
||||||
(prev_percent - next_percent) is that wake's cost. A step where
|
(prev_percent - next_percent) is that wake's cost. Raw percents go
|
||||||
percent went *up* is a recharge, not negative drain, and is skipped
|
through _smooth_percents first, which corrects readings (including
|
||||||
entirely rather than folded in as a weird outlier; a flat step
|
short bursts of them) that are wild outliers against their own local
|
||||||
(0% change) still counts as a real, cheap wake -- excluding those
|
neighborhood -- see that function's docstring for why that catches
|
||||||
would systematically overstate the per-wake cost by only counting
|
noise shapes _reject_outlier_drops can't. A step where percent went
|
||||||
the wakes that happened to tick the percentage down. The remaining
|
*up* is a recharge, not negative drain, and is skipped entirely
|
||||||
steps then get one more pass, _reject_outlier_drops, to catch the
|
rather than folded in as a weird outlier; a flat step (0% change)
|
||||||
single-noisy-reading case that "percent went up" alone can't (see
|
still counts as a real, cheap wake -- excluding those would
|
||||||
that function's docstring). Steps are weighted linearly by recency
|
systematically overstate the per-wake cost by only counting the
|
||||||
(step i of n gets weight i, 1-indexed) so a recent change in usage
|
wakes that happened to tick the percentage down. The remaining steps
|
||||||
pattern shows up quickly instead of being washed out by a long flat
|
then get one more pass, _reject_outlier_drops, to catch whatever
|
||||||
history.
|
single-noisy-reading shape survives smoothing (see that function's
|
||||||
|
docstring). Steps are weighted linearly by recency (step i of n gets
|
||||||
|
weight i, 1-indexed) so a recent change in usage pattern shows up
|
||||||
|
quickly instead of being washed out by a long flat history.
|
||||||
|
|
||||||
The resulting %/wake rate is then converted to wall-clock time using
|
The resulting %/wake rate is then converted to wall-clock time using
|
||||||
the frame's *current* refresh_interval_s and quiet-hours settings
|
the frame's *current* refresh_interval_s and quiet-hours settings
|
||||||
@@ -220,6 +273,7 @@ def battery_estimate_s(frame: Frame, db: Session) -> int | None:
|
|||||||
if len(rows) < MIN_ESTIMATE_SAMPLES + 1:
|
if len(rows) < MIN_ESTIMATE_SAMPLES + 1:
|
||||||
return None
|
return None
|
||||||
percents = list(reversed(rows)) # chronological order
|
percents = list(reversed(rows)) # chronological order
|
||||||
|
percents = _smooth_percents(percents)
|
||||||
|
|
||||||
steps: list[tuple[int, float]] = [] # (recency_weight, drop_pct)
|
steps: list[tuple[int, float]] = [] # (recency_weight, drop_pct)
|
||||||
for i in range(1, len(percents)):
|
for i in range(1, len(percents)):
|
||||||
|
|||||||
+104
-26
@@ -14,6 +14,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from fastapi.responses import FileResponse, Response
|
from fastapi.responses import FileResponse, Response
|
||||||
@@ -23,7 +24,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from .. import grid, mail, quiet_hours
|
from .. import grid, mail, quiet_hours
|
||||||
from ..auth import get_server_settings, require_device
|
from ..auth import get_server_settings, require_device
|
||||||
from ..db import frame_locked, get_db
|
from ..db import SessionLocal, frame_locked, get_db
|
||||||
from ..firmware import firmware_path
|
from ..firmware import firmware_path
|
||||||
from ..global_actions import GLOBAL_ACTIONS
|
from ..global_actions import GLOBAL_ACTIONS
|
||||||
from ..image_pipeline import draw_widget_border, logical_render_size, render_panel, render_placeholder, resolve_border_color
|
from ..image_pipeline import draw_widget_border, logical_render_size, render_panel, render_placeholder, resolve_border_color
|
||||||
@@ -43,7 +44,7 @@ router = APIRouter()
|
|||||||
|
|
||||||
|
|
||||||
def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = None,
|
def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = None,
|
||||||
as_png: bool = False) -> bytes:
|
as_png: bool = False, capture_snapshot: bool = False) -> bytes | tuple[bytes, bytes]:
|
||||||
"""What an unclaimed or widget-less frame displays instead of real
|
"""What an unclaimed or widget-less frame displays instead of real
|
||||||
content -- instructions with a QR, rendered at 200 so the device
|
content -- instructions with a QR, rendered at 200 so the device
|
||||||
treats it as a perfectly normal image and never error-loops. The
|
treats it as a perfectly normal image and never error-loops. The
|
||||||
@@ -60,6 +61,7 @@ def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = Non
|
|||||||
palette_rgb=frame.palette_rgb,
|
palette_rgb=frame.palette_rgb,
|
||||||
manage=manage,
|
manage=manage,
|
||||||
as_png=as_png,
|
as_png=as_png,
|
||||||
|
capture_snapshot=capture_snapshot,
|
||||||
)
|
)
|
||||||
if frame.owner_user_id is None:
|
if frame.owner_user_id is None:
|
||||||
return render_placeholder(
|
return render_placeholder(
|
||||||
@@ -68,6 +70,7 @@ def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = Non
|
|||||||
palette_rgb=frame.palette_rgb,
|
palette_rgb=frame.palette_rgb,
|
||||||
manage=manage,
|
manage=manage,
|
||||||
as_png=as_png,
|
as_png=as_png,
|
||||||
|
capture_snapshot=capture_snapshot,
|
||||||
)
|
)
|
||||||
return render_placeholder(
|
return render_placeholder(
|
||||||
["Almost there!", "Add a widget for this frame at", base],
|
["Almost there!", "Add a widget for this frame at", base],
|
||||||
@@ -76,11 +79,46 @@ def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = Non
|
|||||||
palette_rgb=frame.palette_rgb,
|
palette_rgb=frame.palette_rgb,
|
||||||
manage=manage,
|
manage=manage,
|
||||||
as_png=as_png,
|
as_png=as_png,
|
||||||
|
capture_snapshot=capture_snapshot,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_one_widget(frame_id: int, widget_id: int, orientation: str, panel_w: int, panel_h: int,
|
||||||
|
cell: tuple[int, int, int, int], is_normal_wake: bool,
|
||||||
|
) -> tuple[tuple[int, int, int, int], object] | None:
|
||||||
|
"""Renders exactly one widget on its own DB session, so several of
|
||||||
|
these can run concurrently in a thread pool -- see app/db.py's
|
||||||
|
module docstring: handlers already run multi-threaded (sync
|
||||||
|
handlers in FastAPI's threadpool, one process), and frame_locked/
|
||||||
|
widget_locked's per-frame threading.Lock is what makes that safe,
|
||||||
|
not anything about which Session object is in play. A SQLAlchemy
|
||||||
|
Session itself is never safe to share across threads, so each
|
||||||
|
concurrent render gets a fresh one rather than reusing the
|
||||||
|
request's. Most of a widget's render time is spent waiting on an
|
||||||
|
external call (Immich, a weather provider, CalDAV) with the DB
|
||||||
|
untouched, which is exactly the time this buys back."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
frame = db.get(Frame, frame_id)
|
||||||
|
widget = db.get(Widget, widget_id)
|
||||||
|
if widget is None:
|
||||||
|
return None # deleted between the listing query and this fetch -- skip it, not a 500
|
||||||
|
module = WIDGET_TYPES.get(widget.widget_type)
|
||||||
|
if module is None:
|
||||||
|
return None # unrecognized widget_type -- shouldn't happen, skip defensively rather than 500
|
||||||
|
px, py, pw, ph = grid.cell_to_pixels(orientation, panel_w, panel_h, cell)
|
||||||
|
img = module.render(db, frame, widget, pw, ph, is_normal_wake=is_normal_wake)
|
||||||
|
draw_widget_border(
|
||||||
|
img, widget.border_style, widget.border_thickness,
|
||||||
|
resolve_border_color(widget.border_color_index, frame.palette_rgb),
|
||||||
|
)
|
||||||
|
return (px, py, pw, ph), img
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def _render_widgets(db: Session, frame: Frame, manage: dict | None, is_normal_wake: bool,
|
def _render_widgets(db: Session, frame: Frame, manage: dict | None, is_normal_wake: bool,
|
||||||
as_png: bool = False) -> bytes:
|
as_png: bool = False, capture_snapshot: bool = False) -> bytes | tuple[bytes, bytes]:
|
||||||
"""The widget-system compositor: renders every widget on this frame
|
"""The widget-system compositor: renders every widget on this frame
|
||||||
into its own region (see app/grid.py for grid-cell -> pixel math),
|
into its own region (see app/grid.py for grid-cell -> pixel math),
|
||||||
draws that widget's own optional border directly onto its region
|
draws that widget's own optional border directly onto its region
|
||||||
@@ -89,34 +127,46 @@ def _render_widgets(db: Session, frame: Frame, manage: dict | None, is_normal_wa
|
|||||||
image_pipeline.render_panel for the single shared paste/enhance/
|
image_pipeline.render_panel for the single shared paste/enhance/
|
||||||
overlay/quantize/pack pass. Replaces the old per-mode RENDERERS
|
overlay/quantize/pack pass. Replaces the old per-mode RENDERERS
|
||||||
dict -- a frame can now show several widgets at once instead of
|
dict -- a frame can now show several widgets at once instead of
|
||||||
exactly one mode owning the whole panel."""
|
exactly one mode owning the whole panel.
|
||||||
|
|
||||||
|
Widgets render concurrently (_render_one_widget, each on its own DB
|
||||||
|
session) rather than one at a time -- a layout with several
|
||||||
|
network-backed widgets (photos, weather, calendar) previously paid
|
||||||
|
their fetch latency serially, which could push a single /frame/*
|
||||||
|
response past the firmware's fixed HTTP timeout and show a
|
||||||
|
misleading "server failed" status screen even though the server
|
||||||
|
was simply still working. Futures are submitted in sort_order and
|
||||||
|
collected in that same order (not completion order) -- overlapping
|
||||||
|
widgets must still paint in the original z-order."""
|
||||||
all_widgets = db.scalars(
|
all_widgets = db.scalars(
|
||||||
select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order)
|
select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order)
|
||||||
).all()
|
).all()
|
||||||
panel_w, panel_h = logical_render_size(frame.orientation)
|
panel_w, panel_h = logical_render_size(frame.orientation)
|
||||||
regions = []
|
regions = []
|
||||||
for widget in all_widgets:
|
if all_widgets:
|
||||||
module = WIDGET_TYPES.get(widget.widget_type)
|
with ThreadPoolExecutor(max_workers=min(len(all_widgets), 8)) as pool:
|
||||||
if module is None:
|
futures = [
|
||||||
continue # unrecognized widget_type -- shouldn't happen, skip defensively rather than 500
|
pool.submit(
|
||||||
px, py, pw, ph = grid.cell_to_pixels(
|
_render_one_widget, frame.id, widget.id, frame.orientation, panel_w, panel_h,
|
||||||
frame.orientation, panel_w, panel_h, (widget.x, widget.y, widget.w, widget.h)
|
(widget.x, widget.y, widget.w, widget.h), is_normal_wake,
|
||||||
)
|
)
|
||||||
img = module.render(db, frame, widget, pw, ph, is_normal_wake=is_normal_wake)
|
for widget in all_widgets
|
||||||
draw_widget_border(
|
]
|
||||||
img, widget.border_style, widget.border_thickness,
|
for future in futures:
|
||||||
resolve_border_color(widget.border_color_index, frame.palette_rgb),
|
result = future.result()
|
||||||
)
|
if result is not None:
|
||||||
regions.append(((px, py, pw, ph), img))
|
regions.append(result)
|
||||||
return render_panel(
|
return render_panel(
|
||||||
regions, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
|
regions, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
|
||||||
color_boost=frame.color_boost, contrast_boost=frame.contrast_boost,
|
color_boost=frame.color_boost, contrast_boost=frame.contrast_boost,
|
||||||
dither_strength=frame.dither_strength, manage=manage, as_png=as_png,
|
dither_strength=frame.dither_strength, manage=manage, as_png=as_png,
|
||||||
|
capture_snapshot=capture_snapshot,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _render_frame_content(db: Session, frame: Frame, request: Request | None, manage: dict | None,
|
def _render_frame_content(db: Session, frame: Frame, request: Request | None, manage: dict | None,
|
||||||
is_normal_wake: bool, as_png: bool = False) -> bytes:
|
is_normal_wake: bool, as_png: bool = False,
|
||||||
|
capture_snapshot: bool = False) -> bytes | tuple[bytes, bytes]:
|
||||||
"""The top-level "what does this frame show right now" entry point.
|
"""The top-level "what does this frame show right now" entry point.
|
||||||
An unclaimed frame or one with no widgets yet gets the setup
|
An unclaimed frame or one with no widgets yet gets the setup
|
||||||
placeholder (needs `request` for its QR URLs -- only available on the
|
placeholder (needs `request` for its QR URLs -- only available on the
|
||||||
@@ -134,11 +184,11 @@ def _render_frame_content(db: Session, frame: Frame, request: Request | None, ma
|
|||||||
if request is None:
|
if request is None:
|
||||||
return render_placeholder(
|
return render_placeholder(
|
||||||
["Almost there!"], orientation=frame.orientation, palette_rgb=frame.palette_rgb,
|
["Almost there!"], orientation=frame.orientation, palette_rgb=frame.palette_rgb,
|
||||||
manage=manage, as_png=as_png,
|
manage=manage, as_png=as_png, capture_snapshot=capture_snapshot,
|
||||||
)
|
)
|
||||||
return _setup_placeholder(frame, request, manage=manage, as_png=as_png)
|
return _setup_placeholder(frame, request, manage=manage, as_png=as_png, capture_snapshot=capture_snapshot)
|
||||||
|
|
||||||
return _render_widgets(db, frame, manage, is_normal_wake, as_png=as_png)
|
return _render_widgets(db, frame, manage, is_normal_wake, as_png=as_png, capture_snapshot=capture_snapshot)
|
||||||
|
|
||||||
|
|
||||||
def render_frame_preview_png(db: Session, frame: Frame, request: Request) -> bytes:
|
def render_frame_preview_png(db: Session, frame: Frame, request: Request) -> bytes:
|
||||||
@@ -245,6 +295,17 @@ def _manage_flag(request: Request) -> bool:
|
|||||||
return request.query_params.get("manage") == "1"
|
return request.query_params.get("manage") == "1"
|
||||||
|
|
||||||
|
|
||||||
|
def _record_last_displayed(db: Session, frame: Frame, png_snapshot: bytes) -> None:
|
||||||
|
"""Persists exactly what a device-facing render just sent (upright
|
||||||
|
PNG, manage overlay included if present -- whatever's actually on the
|
||||||
|
panel) as this frame's "now displaying" snapshot, the frozen half of
|
||||||
|
the web UI's header preview pair (see api_frames.py's /now-displaying
|
||||||
|
endpoint and its always-live "up next" counterpart, /preview)."""
|
||||||
|
with frame_locked(db, frame.id) as locked:
|
||||||
|
locked.last_displayed_image = png_snapshot
|
||||||
|
locked.last_displayed_at = time.time()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/frame/image")
|
@router.get("/frame/image")
|
||||||
def frame_image(
|
def frame_image(
|
||||||
request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)
|
request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)
|
||||||
@@ -262,9 +323,14 @@ def frame_image(
|
|||||||
?manage=1 (the manage button) composites the manage overlay onto
|
?manage=1 (the manage button) composites the manage overlay onto
|
||||||
whatever this would have returned anyway -- see build_manage_content.
|
whatever this would have returned anyway -- see build_manage_content.
|
||||||
This is also the "normal wake" that resets any calendar widget's
|
This is also the "normal wake" that resets any calendar widget's
|
||||||
browse position back to today (see app/widgets/calendar.py)."""
|
browse position back to today (see app/widgets/calendar.py).
|
||||||
|
|
||||||
|
Also records what's returned as this frame's "now displaying"
|
||||||
|
snapshot (see _record_last_displayed) -- every other device-facing
|
||||||
|
render endpoint below does the same."""
|
||||||
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
|
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
|
||||||
content = _render_frame_content(db, frame, request, manage, is_normal_wake=True)
|
content, snapshot = _render_frame_content(db, frame, request, manage, is_normal_wake=True, capture_snapshot=True)
|
||||||
|
_record_last_displayed(db, frame, snapshot)
|
||||||
return Response(content=content, media_type="application/octet-stream")
|
return Response(content=content, media_type="application/octet-stream")
|
||||||
|
|
||||||
|
|
||||||
@@ -277,7 +343,10 @@ def frame_advance(request: Request, frame: Frame = Depends(require_device), db:
|
|||||||
device's next-photo button."""
|
device's next-photo button."""
|
||||||
_run_button_actions(db, frame, "next")
|
_run_button_actions(db, frame, "next")
|
||||||
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
|
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
|
||||||
content = _render_frame_content(db, frame, request=None, manage=manage, is_normal_wake=False)
|
content, snapshot = _render_frame_content(
|
||||||
|
db, frame, request=None, manage=manage, is_normal_wake=False, capture_snapshot=True
|
||||||
|
)
|
||||||
|
_record_last_displayed(db, frame, snapshot)
|
||||||
return Response(content=content, media_type="application/octet-stream")
|
return Response(content=content, media_type="application/octet-stream")
|
||||||
|
|
||||||
|
|
||||||
@@ -288,7 +357,10 @@ def frame_back(request: Request, frame: Frame = Depends(require_device), db: Ses
|
|||||||
with nothing to go back to. Used by the device's back-photo button."""
|
with nothing to go back to. Used by the device's back-photo button."""
|
||||||
_run_button_actions(db, frame, "back")
|
_run_button_actions(db, frame, "back")
|
||||||
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
|
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
|
||||||
content = _render_frame_content(db, frame, request=None, manage=manage, is_normal_wake=False)
|
content, snapshot = _render_frame_content(
|
||||||
|
db, frame, request=None, manage=manage, is_normal_wake=False, capture_snapshot=True
|
||||||
|
)
|
||||||
|
_record_last_displayed(db, frame, snapshot)
|
||||||
return Response(content=content, media_type="application/octet-stream")
|
return Response(content=content, media_type="application/octet-stream")
|
||||||
|
|
||||||
|
|
||||||
@@ -303,7 +375,10 @@ def frame_global_next(request: Request, frame: Frame = Depends(require_device),
|
|||||||
firmware/main/next_button.c for the short/long split."""
|
firmware/main/next_button.c for the short/long split."""
|
||||||
_run_global_action(db, frame, "next")
|
_run_global_action(db, frame, "next")
|
||||||
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
|
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
|
||||||
content = _render_frame_content(db, frame, request=None, manage=manage, is_normal_wake=False)
|
content, snapshot = _render_frame_content(
|
||||||
|
db, frame, request=None, manage=manage, is_normal_wake=False, capture_snapshot=True
|
||||||
|
)
|
||||||
|
_record_last_displayed(db, frame, snapshot)
|
||||||
return Response(content=content, media_type="application/octet-stream")
|
return Response(content=content, media_type="application/octet-stream")
|
||||||
|
|
||||||
|
|
||||||
@@ -312,7 +387,10 @@ def frame_global_back(request: Request, frame: Frame = Depends(require_device),
|
|||||||
"""The mirror of /frame/global-next, for a held BACK button."""
|
"""The mirror of /frame/global-next, for a held BACK button."""
|
||||||
_run_global_action(db, frame, "back")
|
_run_global_action(db, frame, "back")
|
||||||
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
|
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
|
||||||
content = _render_frame_content(db, frame, request=None, manage=manage, is_normal_wake=False)
|
content, snapshot = _render_frame_content(
|
||||||
|
db, frame, request=None, manage=manage, is_normal_wake=False, capture_snapshot=True
|
||||||
|
)
|
||||||
|
_record_last_displayed(db, frame, snapshot)
|
||||||
return Response(content=content, media_type="application/octet-stream")
|
return Response(content=content, media_type="application/octet-stream")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from fastapi.templating import Jinja2Templates
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from .. import weather
|
from .. import theme_tokens, weather
|
||||||
from ..auth import can_view_frame, current_user
|
from ..auth import can_view_frame, current_user
|
||||||
from ..calendar_render import CALENDAR_VIEW_LABELS
|
from ..calendar_render import CALENDAR_VIEW_LABELS
|
||||||
from ..db import get_db
|
from ..db import get_db
|
||||||
@@ -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,9 +90,11 @@ 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,
|
||||||
|
themes=theme_tokens.THEMES,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -304,6 +307,7 @@ def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session =
|
|||||||
)
|
)
|
||||||
return templates.TemplateResponse("_widget_dialog_whiteboard.html", {
|
return templates.TemplateResponse("_widget_dialog_whiteboard.html", {
|
||||||
"request": request, "frame": frame, "widget": widget, "user": user,
|
"request": request, "frame": frame, "widget": widget, "user": user,
|
||||||
|
"whiteboard_cfg": whiteboard_cfg,
|
||||||
"whiteboard_source": _whiteboard_source_info(db, whiteboard_cfg),
|
"whiteboard_source": _whiteboard_source_info(db, whiteboard_cfg),
|
||||||
"viewer_has_webdav_creds": viewer_has_webdav_creds, **border_ctx, **button_ctx,
|
"viewer_has_webdav_creds": viewer_has_webdav_creds, **border_ctx, **button_ctx,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import logging
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -35,6 +35,7 @@ from ..auth import (
|
|||||||
verify_password,
|
verify_password,
|
||||||
)
|
)
|
||||||
from ..db import get_db
|
from ..db import get_db
|
||||||
|
from ..logging_setup import LOG_PATH, read_log_tail
|
||||||
from ..models import Frame, PasswordResetToken, PendingClaim, User, UserFrame
|
from ..models import Frame, PasswordResetToken, PendingClaim, User, UserFrame
|
||||||
from .common import valid_http_url
|
from .common import valid_http_url
|
||||||
|
|
||||||
@@ -569,6 +570,7 @@ def _render_admin(request: Request, db: Session, admin: User, notice: str | None
|
|||||||
"smtp": get_server_settings(db),
|
"smtp": get_server_settings(db),
|
||||||
"notice": notice,
|
"notice": notice,
|
||||||
"error": error,
|
"error": error,
|
||||||
|
"active_admin_tab": "main",
|
||||||
})
|
})
|
||||||
return templates.TemplateResponse("admin.html", ctx)
|
return templates.TemplateResponse("admin.html", ctx)
|
||||||
|
|
||||||
@@ -583,6 +585,35 @@ def admin_page(request: Request, db: Session = Depends(get_db)):
|
|||||||
return _render_admin(request, db, user)
|
return _render_admin(request, db, user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/logs", response_class=HTMLResponse)
|
||||||
|
def admin_logs_page(request: Request, lines: int = 500, db: Session = Depends(get_db)):
|
||||||
|
user = current_user(request, db)
|
||||||
|
if user is None:
|
||||||
|
return RedirectResponse("/login", status_code=303)
|
||||||
|
if not user.is_admin:
|
||||||
|
raise HTTPException(403, "Admin only")
|
||||||
|
from .common import shell_context
|
||||||
|
|
||||||
|
lines = max(50, min(lines, 5000))
|
||||||
|
ctx = shell_context(request, db, user, active_nav="admin")
|
||||||
|
ctx.update({
|
||||||
|
"active_admin_tab": "logs",
|
||||||
|
"log_exists": LOG_PATH.exists(),
|
||||||
|
"log_path": str(LOG_PATH),
|
||||||
|
"log_lines": lines,
|
||||||
|
"log_text": read_log_tail(lines),
|
||||||
|
})
|
||||||
|
return templates.TemplateResponse("admin_logs.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/logs/download")
|
||||||
|
def admin_logs_download(request: Request, db: Session = Depends(get_db)):
|
||||||
|
_require_admin_page(request, db)
|
||||||
|
if not LOG_PATH.exists():
|
||||||
|
raise HTTPException(404, "No log file yet")
|
||||||
|
return FileResponse(LOG_PATH, filename="server.log", media_type="text/plain")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/admin/users", response_class=HTMLResponse)
|
@router.post("/admin/users", response_class=HTMLResponse)
|
||||||
def admin_create_user(
|
def admin_create_user(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|||||||
@@ -58,6 +58,15 @@
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// Registering this is what makes Chrome/Android offer the "Add to Home
|
||||||
|
// screen" install prompt -- a manifest link alone isn't enough. Served
|
||||||
|
// from /sw.js (not /static/sw.js) so its scope is the whole app.
|
||||||
|
if ("serviceWorker" in navigator) {
|
||||||
|
window.addEventListener("load", function () {
|
||||||
|
navigator.serviceWorker.register("/sw.js");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Shared display names for widget_type, everywhere one shows up in the
|
// Shared display names for widget_type, everywhere one shows up in the
|
||||||
// UI (Layout canvas, Add-a-widget buttons, button-assignment dropdowns).
|
// UI (Layout canvas, Add-a-widget buttons, button-assignment dropdowns).
|
||||||
const WIDGET_LABELS = {
|
const WIDGET_LABELS = {
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ function renderDeviceStatusBar(device) {
|
|||||||
}
|
}
|
||||||
const now = Date.now() / 1000;
|
const now = Date.now() / 1000;
|
||||||
const rows = [];
|
const rows = [];
|
||||||
|
if (device.expected_next_checkin) {
|
||||||
|
const remaining = device.expected_next_checkin - now;
|
||||||
|
rows.push([
|
||||||
|
'Expected in',
|
||||||
|
remaining > 0 ? `~${formatDuration(remaining)}` : 'Any moment',
|
||||||
|
device.overdue,
|
||||||
|
]);
|
||||||
|
}
|
||||||
const ago = formatDuration(Math.max(0, now - device.last_seen));
|
const ago = formatDuration(Math.max(0, now - device.last_seen));
|
||||||
rows.push(['Last seen', `${ago} ago`, device.overdue]);
|
rows.push(['Last seen', `${ago} ago`, device.overdue]);
|
||||||
if (device.firmware_version) {
|
if (device.firmware_version) {
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ async function loadControl() {
|
|||||||
|
|
||||||
document.getElementById('take-control').addEventListener('click', takeControl);
|
document.getElementById('take-control').addEventListener('click', takeControl);
|
||||||
|
|
||||||
// ---- Advanced configuration: color palette ----
|
// ---- Advanced configuration: color palette(s) ----
|
||||||
//
|
//
|
||||||
// Hex field and R/G/B number fields are kept in sync live, both
|
// Hex field and R/G/B number fields are kept in sync live, both
|
||||||
// directions -- editing either updates the other plus the preview
|
// directions -- editing either updates the other plus the preview
|
||||||
@@ -122,9 +122,15 @@ document.getElementById('take-control').addEventListener('click', takeControl);
|
|||||||
// the server already validates as #rrggbb); the R/G/B fields are purely
|
// the server already validates as #rrggbb); the R/G/B fields are purely
|
||||||
// an alternate, more precise way to arrive at the same value than
|
// an alternate, more precise way to arrive at the same value than
|
||||||
// eyeballing a color-picker swatch.
|
// eyeballing a color-picker swatch.
|
||||||
|
//
|
||||||
|
// Parameterized by classPrefix ("palette" for the main one, "photo-
|
||||||
|
// palette" for the photos-only one added alongside it) rather than
|
||||||
|
// duplicated wholesale -- there are exactly two real instances of this,
|
||||||
|
// not a speculative future one, and the two would otherwise be ~90
|
||||||
|
// near-identical lines apart.
|
||||||
|
|
||||||
function paletteHexInputs() {
|
function paletteHexInputs(classPrefix) {
|
||||||
return Array.from(document.querySelectorAll('.palette-hex'))
|
return Array.from(document.querySelectorAll(`.${classPrefix}-hex`))
|
||||||
.sort((a, b) => Number(a.dataset.index) - Number(b.dataset.index));
|
.sort((a, b) => Number(a.dataset.index) - Number(b.dataset.index));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,45 +146,53 @@ function rgbFromHex(hex) {
|
|||||||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||||
}
|
}
|
||||||
|
|
||||||
function paletteFieldsFor(index) {
|
function paletteFieldsFor(classPrefix, index) {
|
||||||
const at = (cls) => document.querySelector(`.${cls}[data-index="${index}"]`);
|
const at = (cls) => document.querySelector(`.${cls}[data-index="${index}"]`);
|
||||||
return { hex: at('palette-hex'), r: at('palette-r'), g: at('palette-g'), b: at('palette-b'), swatch: at('palette-swatch-preview') };
|
return {
|
||||||
|
hex: at(`${classPrefix}-hex`), r: at(`${classPrefix}-r`), g: at(`${classPrefix}-g`), b: at(`${classPrefix}-b`),
|
||||||
|
swatch: at(`${classPrefix}-swatch-preview`),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncPaletteFromHex(index) {
|
function syncPaletteFromHex(classPrefix, index) {
|
||||||
const f = paletteFieldsFor(index);
|
const f = paletteFieldsFor(classPrefix, index);
|
||||||
const rgb = rgbFromHex(f.hex.value);
|
const rgb = rgbFromHex(f.hex.value);
|
||||||
if (!rgb) return;
|
if (!rgb) return;
|
||||||
[f.r.value, f.g.value, f.b.value] = rgb;
|
[f.r.value, f.g.value, f.b.value] = rgb;
|
||||||
f.swatch.style.background = f.hex.value;
|
f.swatch.style.background = f.hex.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncPaletteFromRgb(index) {
|
function syncPaletteFromRgb(classPrefix, index) {
|
||||||
const f = paletteFieldsFor(index);
|
const f = paletteFieldsFor(classPrefix, index);
|
||||||
const hex = hexFromRgb(f.r.value, f.g.value, f.b.value);
|
const hex = hexFromRgb(f.r.value, f.g.value, f.b.value);
|
||||||
f.hex.value = hex;
|
f.hex.value = hex;
|
||||||
f.swatch.style.background = hex;
|
f.swatch.style.background = hex;
|
||||||
}
|
}
|
||||||
|
|
||||||
const palettePickerCount = paletteHexInputs().length;
|
function wirePaletteInputs(classPrefix) {
|
||||||
for (let i = 0; i < palettePickerCount; i++) {
|
const count = paletteHexInputs(classPrefix).length;
|
||||||
const f = paletteFieldsFor(i);
|
for (let i = 0; i < count; i++) {
|
||||||
f.hex.addEventListener('input', () => syncPaletteFromHex(i));
|
const f = paletteFieldsFor(classPrefix, i);
|
||||||
[f.r, f.g, f.b].forEach((el) => el.addEventListener('input', () => syncPaletteFromRgb(i)));
|
f.hex.addEventListener('input', () => syncPaletteFromHex(classPrefix, i));
|
||||||
|
[f.r, f.g, f.b].forEach((el) => el.addEventListener('input', () => syncPaletteFromRgb(classPrefix, i)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
wirePaletteInputs('palette');
|
||||||
|
wirePaletteInputs('photo-palette');
|
||||||
|
|
||||||
// Sliders: live numeric readout next to each, no save until the button
|
// Sliders: live numeric readout next to each, no save until the button
|
||||||
// below is clicked.
|
// below is clicked.
|
||||||
['color_boost', 'contrast_boost', 'dither_strength'].forEach((id) => {
|
['color_boost', 'contrast_boost', 'dither_strength', 'photo_dither_strength'].forEach((id) => {
|
||||||
const input = document.getElementById(id);
|
const input = document.getElementById(id);
|
||||||
const readout = document.getElementById(`${id}_value`);
|
const readout = document.getElementById(`${id}_value`);
|
||||||
input.addEventListener('input', () => { readout.textContent = Number(input.value).toFixed(2); });
|
input.addEventListener('input', () => { readout.textContent = Number(input.value).toFixed(2); });
|
||||||
});
|
});
|
||||||
|
|
||||||
async function savePalette(extra) {
|
async function savePalette(classPrefix, paletteFormKey, extra) {
|
||||||
const body = new URLSearchParams(extra || {});
|
const body = new URLSearchParams(extra || {});
|
||||||
for (const input of paletteHexInputs()) {
|
for (const input of paletteHexInputs(classPrefix)) {
|
||||||
body.append('palette', input.value);
|
body.append(paletteFormKey, input.value);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`${window.FRAME_API}/config`, {
|
const resp = await fetch(`${window.FRAME_API}/config`, {
|
||||||
@@ -195,7 +209,7 @@ async function savePalette(extra) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('palette-save').addEventListener('click', () => {
|
document.getElementById('palette-save').addEventListener('click', () => {
|
||||||
savePalette({
|
savePalette('palette', 'palette', {
|
||||||
color_boost: document.getElementById('color_boost').value,
|
color_boost: document.getElementById('color_boost').value,
|
||||||
contrast_boost: document.getElementById('contrast_boost').value,
|
contrast_boost: document.getElementById('contrast_boost').value,
|
||||||
dither_strength: document.getElementById('dither_strength').value,
|
dither_strength: document.getElementById('dither_strength').value,
|
||||||
@@ -203,16 +217,65 @@ document.getElementById('palette-save').addEventListener('click', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('palette-reset').addEventListener('click', () => {
|
document.getElementById('palette-reset').addEventListener('click', () => {
|
||||||
const inputs = paletteHexInputs();
|
const inputs = paletteHexInputs('palette');
|
||||||
window.DEFAULT_PALETTE_HEX.forEach((hex, i) => {
|
window.DEFAULT_PALETTE_HEX.forEach((hex, i) => {
|
||||||
inputs[i].value = hex;
|
inputs[i].value = hex;
|
||||||
syncPaletteFromHex(i);
|
syncPaletteFromHex('palette', i);
|
||||||
});
|
});
|
||||||
['color_boost', 'contrast_boost', 'dither_strength'].forEach((id) => {
|
['color_boost', 'contrast_boost', 'dither_strength'].forEach((id) => {
|
||||||
document.getElementById(id).value = '1';
|
document.getElementById(id).value = '1';
|
||||||
document.getElementById(`${id}_value`).textContent = '1.00';
|
document.getElementById(`${id}_value`).textContent = '1.00';
|
||||||
});
|
});
|
||||||
savePalette({ palette_reset: 'true', color_boost: '1', contrast_boost: '1', dither_strength: '1' });
|
savePalette('palette', 'palette', { 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('palette');
|
||||||
|
window.CALIBRATED_SPECTRA6_HEX.forEach((hex, i) => {
|
||||||
|
inputs[i].value = hex;
|
||||||
|
syncPaletteFromHex('palette', i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Photos configuration: its own separate palette/dithering ----
|
||||||
|
|
||||||
|
document.getElementById('photo-palette-save').addEventListener('click', () => {
|
||||||
|
savePalette('photo-palette', 'photo_palette', {
|
||||||
|
photo_dither_strength: document.getElementById('photo_dither_strength').value,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('photo-palette-reset').addEventListener('click', () => {
|
||||||
|
const inputs = paletteHexInputs('photo-palette');
|
||||||
|
window.DEFAULT_PALETTE_HEX.forEach((hex, i) => {
|
||||||
|
inputs[i].value = hex;
|
||||||
|
syncPaletteFromHex('photo-palette', i);
|
||||||
|
});
|
||||||
|
document.getElementById('photo_dither_strength').value = '1';
|
||||||
|
document.getElementById('photo_dither_strength_value').textContent = '1.00';
|
||||||
|
savePalette('photo-palette', 'photo_palette', { photo_palette_reset: 'true', photo_dither_strength: '1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Theme ----
|
||||||
|
|
||||||
|
document.getElementById('theme-save').addEventListener('click', async () => {
|
||||||
|
const body = new URLSearchParams({ theme: document.getElementById('theme-select').value });
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${window.FRAME_API}/config`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(await apiError(resp));
|
||||||
|
showStatus(true, 'Saved.');
|
||||||
|
loadPreview();
|
||||||
|
} catch (e) {
|
||||||
|
showStatus(false, e.message);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- Preview: current photo vs. how it renders with saved settings ----
|
// ---- Preview: current photo vs. how it renders with saved settings ----
|
||||||
@@ -339,8 +402,15 @@ async function loadFirmwareCheck(force) {
|
|||||||
statusEl.textContent = 'Waiting for the frame to check in before it can look up the right build.';
|
statusEl.textContent = 'Waiting for the frame to check in before it can look up the right build.';
|
||||||
btn.style.display = 'none';
|
btn.style.display = 'none';
|
||||||
} else if (data.update_available) {
|
} else if (data.update_available) {
|
||||||
statusEl.textContent = `Update available: v${data.latest_version}.`;
|
statusEl.textContent = `Update available: v${data.latest_version}` +
|
||||||
|
(data.running_version ? ` (currently running v${data.running_version}).` : '.');
|
||||||
btn.style.display = 'inline-block';
|
btn.style.display = 'inline-block';
|
||||||
|
} else if (data.latest_version && data.running_version && data.running_version !== data.latest_version) {
|
||||||
|
// Already staged (or auto-applied) but the frame hasn't woken up
|
||||||
|
// and picked it up yet -- not "up to date" until it actually has.
|
||||||
|
statusEl.textContent = `v${data.latest_version} staged -- applies next time the frame wakes ` +
|
||||||
|
`(currently running v${data.running_version}).`;
|
||||||
|
btn.style.display = 'none';
|
||||||
} else if (data.latest_version) {
|
} else if (data.latest_version) {
|
||||||
statusEl.textContent = `Up to date (v${data.latest_version}).`;
|
statusEl.textContent = `Up to date (v${data.latest_version}).`;
|
||||||
btn.style.display = 'none';
|
btn.style.display = 'none';
|
||||||
|
|||||||
@@ -58,48 +58,31 @@
|
|||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// Live "how it's displaying" thumbnail. A real composite render (same
|
// Now-displaying / up-next header preview pair. "Up next" is a real
|
||||||
// pipeline /frame/image uses), not a cached snapshot, so it's on a slow
|
// composite render (same pipeline /frame/image uses), not a cached
|
||||||
// poll rather than something tighter like the 10s device-status poll --
|
// snapshot, so it's on a slow poll rather than something tighter like
|
||||||
// no need to hit Immich/calendar/whiteboard sources that often just for
|
// the 10s device-status poll -- no need to hit Immich/calendar/
|
||||||
// a header thumbnail. Click enlarges it in a dialog (which also fetches
|
// whiteboard sources that often just for a header thumbnail, and it
|
||||||
// a fresh render); clicking the enlarged image refreshes it again.
|
// shows layout edits live as they're made. "Now displaying" is the
|
||||||
|
// opposite: exactly the bytes last actually sent to the device (see
|
||||||
|
// routers/device.py's _record_last_displayed), frozen until the
|
||||||
|
// device's next real wake even while the layout is being edited live --
|
||||||
|
// that contrast is the point of showing both side by side.
|
||||||
(function () {
|
(function () {
|
||||||
var thumb = document.getElementById('frame-preview-thumb');
|
var nextThumb = document.getElementById('frame-preview-thumb');
|
||||||
var dialog = document.getElementById('frame-preview-dialog');
|
var nextDialog = document.getElementById('frame-preview-dialog');
|
||||||
var bigImg = document.getElementById('frame-preview-dialog-img');
|
var nextBigImg = document.getElementById('frame-preview-dialog-img');
|
||||||
var closeBtn = document.getElementById('frame-preview-dialog-close');
|
var nextCloseBtn = document.getElementById('frame-preview-dialog-close');
|
||||||
if (!thumb || !window.FRAME_BASE_API) return;
|
var nowThumb = document.getElementById('frame-preview-now-thumb');
|
||||||
|
var nowDialog = document.getElementById('frame-preview-now-dialog');
|
||||||
|
var nowBigImg = document.getElementById('frame-preview-now-dialog-img');
|
||||||
|
var nowCloseBtn = document.getElementById('frame-preview-now-dialog-close');
|
||||||
|
if (!nextThumb || !window.FRAME_BASE_API) return;
|
||||||
|
|
||||||
function previewUrl() {
|
|
||||||
return `${window.FRAME_BASE_API}/preview?t=${Date.now()}`;
|
|
||||||
}
|
|
||||||
function refreshThumb() {
|
|
||||||
thumb.src = previewUrl();
|
|
||||||
}
|
|
||||||
// Opening the dialog (or clicking the big image inside it) fetches a
|
|
||||||
// fresh render and keeps the header thumb in sync, so this single path
|
|
||||||
// covers both "enlarge" and the old click-to-refresh behavior.
|
|
||||||
function refreshBig() {
|
|
||||||
var url = previewUrl();
|
|
||||||
bigImg.src = url;
|
|
||||||
thumb.src = url;
|
|
||||||
}
|
|
||||||
|
|
||||||
thumb.addEventListener('click', function () {
|
|
||||||
if (!dialog) { refreshThumb(); return; }
|
|
||||||
refreshBig();
|
|
||||||
dialog.showModal();
|
|
||||||
});
|
|
||||||
refreshThumb();
|
|
||||||
setInterval(refreshThumb, 60000);
|
|
||||||
|
|
||||||
if (dialog && bigImg && closeBtn) {
|
|
||||||
bigImg.addEventListener('click', refreshBig);
|
|
||||||
closeBtn.addEventListener('click', function () { dialog.close(); });
|
|
||||||
// Same backdrop-click-to-close trick as #widget-dialog: a click that
|
// Same backdrop-click-to-close trick as #widget-dialog: a click that
|
||||||
// lands on the dialog element itself (not its content box) means the
|
// lands on the dialog element itself (not its content box) means the
|
||||||
// backdrop was hit.
|
// backdrop was hit.
|
||||||
|
function closeOnBackdropClick(dialog) {
|
||||||
dialog.addEventListener('click', function (e) {
|
dialog.addEventListener('click', function (e) {
|
||||||
if (e.target !== dialog) return;
|
if (e.target !== dialog) return;
|
||||||
var rect = dialog.getBoundingClientRect();
|
var rect = dialog.getBoundingClientRect();
|
||||||
@@ -107,4 +90,80 @@
|
|||||||
if (!inside) dialog.close();
|
if (!inside) dialog.close();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function nextUrl() {
|
||||||
|
return `${window.FRAME_BASE_API}/preview?t=${Date.now()}`;
|
||||||
|
}
|
||||||
|
function refreshNext() {
|
||||||
|
nextThumb.src = nextUrl();
|
||||||
|
}
|
||||||
|
// Opening the dialog (or clicking the big image inside it) fetches a
|
||||||
|
// fresh render and keeps the header thumb in sync, so this single path
|
||||||
|
// covers both "enlarge" and the old click-to-refresh behavior.
|
||||||
|
function refreshNextBig() {
|
||||||
|
var url = nextUrl();
|
||||||
|
nextBigImg.src = url;
|
||||||
|
nextThumb.src = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
nextThumb.addEventListener('click', function () {
|
||||||
|
if (!nextDialog) { refreshNext(); return; }
|
||||||
|
refreshNextBig();
|
||||||
|
nextDialog.showModal();
|
||||||
|
});
|
||||||
|
refreshNext();
|
||||||
|
setInterval(refreshNext, 60000);
|
||||||
|
|
||||||
|
if (nextDialog && nextBigImg && nextCloseBtn) {
|
||||||
|
nextBigImg.addEventListener('click', refreshNextBig);
|
||||||
|
nextCloseBtn.addEventListener('click', function () { nextDialog.close(); });
|
||||||
|
closeOnBackdropClick(nextDialog);
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Now displaying" fetches rather than sets .src directly: it needs to
|
||||||
|
// tell a 404 (device hasn't fetched yet) apart from a real image to
|
||||||
|
// show its own empty state instead of a broken-image icon, and reads
|
||||||
|
// the capture time off X-Displayed-At for the "N ago" tooltip.
|
||||||
|
if (nowThumb) {
|
||||||
|
var nowObjectUrl = null;
|
||||||
|
function refreshNow() {
|
||||||
|
fetch(`${window.FRAME_BASE_API}/now-displaying?t=${Date.now()}`)
|
||||||
|
.then(function (resp) {
|
||||||
|
if (!resp.ok) {
|
||||||
|
nowThumb.classList.add('frame-preview-thumb-empty');
|
||||||
|
nowThumb.removeAttribute('src');
|
||||||
|
nowThumb.title = "Now displaying -- hasn't shown anything yet";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
var displayedAt = resp.headers.get('X-Displayed-At');
|
||||||
|
nowThumb.title = displayedAt
|
||||||
|
? `Now displaying -- ${formatDuration(Math.max(0, Date.now() / 1000 - parseFloat(displayedAt)))} ago -- click to enlarge`
|
||||||
|
: 'Now displaying -- click to enlarge';
|
||||||
|
return resp.blob();
|
||||||
|
})
|
||||||
|
.then(function (blob) {
|
||||||
|
if (!blob) return;
|
||||||
|
nowThumb.classList.remove('frame-preview-thumb-empty');
|
||||||
|
var url = URL.createObjectURL(blob);
|
||||||
|
var old = nowObjectUrl;
|
||||||
|
nowObjectUrl = url;
|
||||||
|
nowThumb.src = url;
|
||||||
|
if (old) URL.revokeObjectURL(old);
|
||||||
|
})
|
||||||
|
.catch(function () { /* transient failure -- leave the last-known thumb showing */ });
|
||||||
|
}
|
||||||
|
|
||||||
|
nowThumb.addEventListener('click', function () {
|
||||||
|
if (nowThumb.classList.contains('frame-preview-thumb-empty') || !nowDialog) return;
|
||||||
|
nowBigImg.src = nowThumb.src;
|
||||||
|
nowDialog.showModal();
|
||||||
|
});
|
||||||
|
refreshNow();
|
||||||
|
setInterval(refreshNow, 60000);
|
||||||
|
|
||||||
|
if (nowDialog && nowCloseBtn) {
|
||||||
|
nowCloseBtn.addEventListener('click', function () { nowDialog.close(); });
|
||||||
|
closeOnBackdropClick(nowDialog);
|
||||||
|
}
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 501 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.1 KiB |
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "ESPresso Frame",
|
||||||
|
"short_name": "ESPresso",
|
||||||
|
"description": "Manage your e-ink photo frames.",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#f5f6f8",
|
||||||
|
"theme_color": "#2563eb",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "/static/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||||
|
{ "src": "/static/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// Presence-only service worker: satisfies the "installable" requirement
|
||||||
|
// (Chrome/Android in particular checks for a controlling SW with a fetch
|
||||||
|
// handler) without adding an offline cache -- every request just goes to
|
||||||
|
// the network as normal. Served from / (see app/main.py's /sw.js route)
|
||||||
|
// so its scope covers the whole app, not just /static/.
|
||||||
|
self.addEventListener("install", () => self.skipWaiting());
|
||||||
|
self.addEventListener("activate", (event) => event.waitUntil(self.clients.claim()));
|
||||||
|
self.addEventListener("fetch", (event) => event.respondWith(fetch(event.request)));
|
||||||
@@ -156,6 +156,24 @@ button.linklike:hover { color: var(--text); background: none; }
|
|||||||
.admin-inline-form { display: flex; gap: 8px; align-items: center; margin-top: 6px; }
|
.admin-inline-form { display: flex; gap: 8px; align-items: center; margin-top: 6px; }
|
||||||
.admin-inline-form input[type="text"] { margin-top: 0; flex: 1; }
|
.admin-inline-form input[type="text"] { margin-top: 0; flex: 1; }
|
||||||
|
|
||||||
|
.log-view-controls { display: flex; gap: 10px; align-items: center; margin: 10px 0; font-size: 13px; }
|
||||||
|
.log-view-controls a:not(.btn-inline) { color: var(--text-muted); }
|
||||||
|
.log-view-controls a.active { color: var(--accent); font-weight: 600; }
|
||||||
|
.log-view {
|
||||||
|
background: var(--surface-alt);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
max-height: 65vh;
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
font-family: ui-monospace, "SF Mono", Consolas, monospace;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
h2.card-title, summary.card-title {
|
h2.card-title, summary.card-title {
|
||||||
font-size: 14.5px;
|
font-size: 14.5px;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
@@ -243,6 +261,36 @@ input[type="range"] {
|
|||||||
}
|
}
|
||||||
.card + .card { margin-top: 20px; }
|
.card + .card { margin-top: 20px; }
|
||||||
|
|
||||||
|
/* Installed as a standalone app, the boxed-card look reads as "still a
|
||||||
|
website" -- flatten page-level cards into the page background so it
|
||||||
|
feels native. Cards inside the widget dialog keep their box: they're
|
||||||
|
grouping subsections of one form, not top-level page furniture. */
|
||||||
|
@media (display-mode: standalone) {
|
||||||
|
.card {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
.card + .card {
|
||||||
|
margin-top: 4px;
|
||||||
|
padding-top: 24px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
#widget-dialog-body .card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 20px 22px 22px;
|
||||||
|
}
|
||||||
|
#widget-dialog-body .card + .card {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding-top: 22px;
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
label { display: block; margin-top: 16px; font-size: 13px; font-weight: 600; color: var(--text); }
|
label { display: block; margin-top: 16px; font-size: 13px; font-weight: 600; color: var(--text); }
|
||||||
label:first-child { margin-top: 0; }
|
label:first-child { margin-top: 0; }
|
||||||
|
|
||||||
@@ -719,13 +767,24 @@ code {
|
|||||||
}
|
}
|
||||||
.frame-name-edit button { margin-top: 0; }
|
.frame-name-edit button { margin-top: 0; }
|
||||||
|
|
||||||
|
.frame-preview-pair {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-left: 12px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.frame-preview-arrow {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
.frame-preview-thumb {
|
.frame-preview-thumb {
|
||||||
height: 44px;
|
height: 44px;
|
||||||
width: auto;
|
width: auto;
|
||||||
max-width: 130px;
|
max-width: 130px;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
margin-left: 12px;
|
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: var(--surface-alt);
|
background: var(--surface-alt);
|
||||||
@@ -733,6 +792,13 @@ code {
|
|||||||
transition: opacity .12s ease;
|
transition: opacity .12s ease;
|
||||||
}
|
}
|
||||||
.frame-preview-thumb:hover { opacity: 0.8; }
|
.frame-preview-thumb:hover { opacity: 0.8; }
|
||||||
|
.frame-preview-thumb-empty {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: default;
|
||||||
|
width: 60px;
|
||||||
|
font-size: 0; /* no src yet -- suppresses the browser's fallback alt-text render */
|
||||||
|
}
|
||||||
|
.frame-preview-thumb-empty:hover { opacity: 0.3; }
|
||||||
|
|
||||||
.frame-preview-dialog {
|
.frame-preview-dialog {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ function initBatteryDialog() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const body = new URLSearchParams({
|
const body = new URLSearchParams({
|
||||||
battery_mode: document.getElementById('battery_mode').value,
|
battery_mode: document.getElementById('battery_mode').value,
|
||||||
|
battery_render_style: document.getElementById('battery_render_style').value,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`${window.FRAME_API}/config`, {
|
const resp = await fetch(`${window.FRAME_API}/config`, {
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ function initCalendarDialog() {
|
|||||||
calendar_week_days: document.getElementById('calendar_week_days').value,
|
calendar_week_days: document.getElementById('calendar_week_days').value,
|
||||||
calendar_week_layout: document.getElementById('calendar_week_layout').value,
|
calendar_week_layout: document.getElementById('calendar_week_layout').value,
|
||||||
calendar_week_start_offset: document.getElementById('calendar_week_start_offset').value,
|
calendar_week_start_offset: document.getElementById('calendar_week_start_offset').value,
|
||||||
|
calendar_render_style: document.getElementById('calendar_render_style').value,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`${window.FRAME_API}/config`, {
|
const resp = await fetch(`${window.FRAME_API}/config`, {
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ function initStaticDialog() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const body = new URLSearchParams({
|
const body = new URLSearchParams({
|
||||||
display_mode: document.getElementById('display_mode').value,
|
display_mode: document.getElementById('display_mode').value,
|
||||||
|
static_render_style: document.getElementById('static_render_style').value,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`${window.FRAME_API}/config`, {
|
const resp = await fetch(`${window.FRAME_API}/config`, {
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ function initTasksDialog() {
|
|||||||
const body = new URLSearchParams({
|
const body = new URLSearchParams({
|
||||||
tasks_name: document.getElementById('tasks_name').value,
|
tasks_name: document.getElementById('tasks_name').value,
|
||||||
tasks_show_completed: String(document.getElementById('tasks_show_completed').checked),
|
tasks_show_completed: String(document.getElementById('tasks_show_completed').checked),
|
||||||
|
tasks_render_style: document.getElementById('tasks_render_style').value,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`${window.FRAME_API}/config`, {
|
const resp = await fetch(`${window.FRAME_API}/config`, {
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ function initTextDialog() {
|
|||||||
text_font_size: document.getElementById('text_font_size').value,
|
text_font_size: document.getElementById('text_font_size').value,
|
||||||
text_align: document.getElementById('text_align').value,
|
text_align: document.getElementById('text_align').value,
|
||||||
text_background_color: document.getElementById('text_background_color').value,
|
text_background_color: document.getElementById('text_background_color').value,
|
||||||
|
text_render_style: document.getElementById('text_render_style').value,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`${window.FRAME_API}/config`, {
|
const resp = await fetch(`${window.FRAME_API}/config`, {
|
||||||
|
|||||||
@@ -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`, {
|
||||||
|
|||||||
@@ -90,6 +90,25 @@ function initWhiteboardDialog() {
|
|||||||
whiteboardClearBtn.addEventListener('click', clearWhiteboardSource);
|
whiteboardClearBtn.addEventListener('click', clearWhiteboardSource);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document.getElementById('whiteboard-config-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
whiteboard_render_style: document.getElementById('whiteboard_render_style').value,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${window.FRAME_API}/config`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(await apiError(resp));
|
||||||
|
showStatus(true, 'Saved.');
|
||||||
|
loadWhiteboardPreview(false);
|
||||||
|
} catch (e) {
|
||||||
|
showStatus(false, e.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Shows whatever's already cached (cheap, no refetch) on open; the
|
// Shows whatever's already cached (cheap, no refetch) on open; the
|
||||||
// button is the one place that means "no really, go check now" --
|
// button is the one place that means "no really, go check now" --
|
||||||
// bypasses the fetch throttle server-side (see api_widget_preview_
|
// bypasses the fetch throttle server-side (see api_widget_preview_
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<nav class="tabs">
|
||||||
|
<a href="/admin" class="{% if active_admin_tab == 'main' %}active{% endif %}">Users & Frames</a>
|
||||||
|
<a href="/admin/logs" class="{% if active_admin_tab == 'logs' %}active{% endif %}">Server Logs</a>
|
||||||
|
</nav>
|
||||||
@@ -7,10 +7,19 @@
|
|||||||
<button type="button" class="btn-inline" id="frame-name-save">Save</button>
|
<button type="button" class="btn-inline" id="frame-name-save">Save</button>
|
||||||
<button type="button" class="btn-inline secondary" id="frame-name-cancel">Cancel</button>
|
<button type="button" class="btn-inline secondary" id="frame-name-cancel">Cancel</button>
|
||||||
</span>
|
</span>
|
||||||
<img id="frame-preview-thumb" class="frame-preview-thumb" alt="Live preview of what the frame is displaying" title="What the frame is currently displaying -- click to enlarge">
|
<span class="frame-preview-pair">
|
||||||
|
<img id="frame-preview-now-thumb" class="frame-preview-thumb frame-preview-thumb-empty" alt="What the frame is currently displaying" title="Now displaying">
|
||||||
|
<span class="frame-preview-arrow" aria-hidden="true">→</span>
|
||||||
|
<img id="frame-preview-thumb" class="frame-preview-thumb" alt="Live preview of what the frame will show next" title="Up next -- live preview, updates as you edit the layout -- click to enlarge">
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<dialog id="frame-preview-now-dialog" class="frame-preview-dialog">
|
||||||
|
<button type="button" id="frame-preview-now-dialog-close" class="icon-btn" aria-label="Close" title="Close">×</button>
|
||||||
|
<img id="frame-preview-now-dialog-img" alt="What the frame is currently displaying">
|
||||||
|
</dialog>
|
||||||
|
|
||||||
<dialog id="frame-preview-dialog" class="frame-preview-dialog">
|
<dialog id="frame-preview-dialog" class="frame-preview-dialog">
|
||||||
<button type="button" id="frame-preview-dialog-close" class="icon-btn" aria-label="Close" title="Close">×</button>
|
<button type="button" id="frame-preview-dialog-close" class="icon-btn" aria-label="Close" title="Close">×</button>
|
||||||
<img id="frame-preview-dialog-img" alt="Live preview of what the frame is displaying" title="Click to refresh">
|
<img id="frame-preview-dialog-img" alt="Live preview of what the frame will show next" title="Click to refresh">
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,12 @@
|
|||||||
<option value="detailed" {% if not battery_cfg or battery_cfg.mode == 'detailed' %}selected{% endif %}>Detailed (+ estimated time left, last report)</option>
|
<option value="detailed" {% if not battery_cfg or battery_cfg.mode == 'detailed' %}selected{% endif %}>Detailed (+ estimated time left, last report)</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label>Render style
|
||||||
|
<select id="battery_render_style">
|
||||||
|
<option value="classic" {% if not battery_cfg or battery_cfg.render_style == 'classic' %}selected{% endif %}>Classic (hand-drawn icon)</option>
|
||||||
|
<option value="modern" {% if battery_cfg and battery_cfg.render_style == 'modern' %}selected{% endif %}>Modern (experimental)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<button type="submit">Save</button>
|
<button type="submit">Save</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -40,6 +40,12 @@
|
|||||||
<p class="sub" style="margin-top: 4px;">0 = starts today, negative = starts in the
|
<p class="sub" style="margin-top: 4px;">0 = starts today, negative = starts in the
|
||||||
past, positive = starts in the future. Only used when Days to show isn't 7.</p>
|
past, positive = starts in the future. Only used when Days to show isn't 7.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<label>Render style
|
||||||
|
<select id="calendar_render_style">
|
||||||
|
<option value="classic" {% if calendar_cfg.render_style == 'classic' %}selected{% endif %}>Classic (hand-drawn)</option>
|
||||||
|
<option value="modern" {% if calendar_cfg.render_style == 'modern' %}selected{% endif %}>Modern (experimental, all views)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<button type="submit">Save</button>
|
<button type="submit">Save</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,12 @@
|
|||||||
exactly without cropping (an image with a different aspect ratio
|
exactly without cropping (an image with a different aspect ratio
|
||||||
looks stretched); <strong>Shrink to fit</strong> shows the whole
|
looks stretched); <strong>Shrink to fit</strong> shows the whole
|
||||||
image, letterboxed if needed.</p>
|
image, letterboxed if needed.</p>
|
||||||
|
<label>Render style
|
||||||
|
<select id="static_render_style">
|
||||||
|
<option value="classic" {% if not static_cfg or static_cfg.render_style == 'classic' %}selected{% endif %}>Classic (no frame)</option>
|
||||||
|
<option value="modern" {% if static_cfg and static_cfg.render_style == 'modern' %}selected{% endif %}>Modern (rounded-corner card, experimental)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<button type="submit">Save</button>
|
<button type="submit">Save</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -55,6 +55,12 @@
|
|||||||
<input type="checkbox" id="tasks_show_completed" {% if task_cfg.show_completed %}checked{% endif %}>
|
<input type="checkbox" id="tasks_show_completed" {% if task_cfg.show_completed %}checked{% endif %}>
|
||||||
<label for="tasks_show_completed">Also show tasks completed in the last 24 hours</label>
|
<label for="tasks_show_completed">Also show tasks completed in the last 24 hours</label>
|
||||||
</div>
|
</div>
|
||||||
|
<label>Render style
|
||||||
|
<select id="tasks_render_style">
|
||||||
|
<option value="classic" {% if task_cfg.render_style == 'classic' %}selected{% endif %}>Classic (hand-drawn checklist)</option>
|
||||||
|
<option value="modern" {% if task_cfg.render_style == 'modern' %}selected{% endif %}>Modern (experimental)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<button type="submit">Save</button>
|
<button type="submit">Save</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -43,6 +43,12 @@
|
|||||||
<label>Background color
|
<label>Background color
|
||||||
<input type="color" id="text_background_color" value="{{ text_cfg.background_color if text_cfg else '#ffffff' }}">
|
<input type="color" id="text_background_color" value="{{ text_cfg.background_color if text_cfg else '#ffffff' }}">
|
||||||
</label>
|
</label>
|
||||||
|
<label>Render style
|
||||||
|
<select id="text_render_style">
|
||||||
|
<option value="classic" {% if not text_cfg or text_cfg.render_style == 'classic' %}selected{% endif %}>Classic (hand-drawn text)</option>
|
||||||
|
<option value="modern" {% if text_cfg and text_cfg.render_style == 'modern' %}selected{% endif %}>Modern (experimental)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<button type="submit">Save</button>
|
<button type="submit">Save</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -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() %}
|
||||||
|
|||||||
@@ -50,6 +50,19 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="card" style="margin-top: 20px;">
|
||||||
|
<h2 class="card-title">Settings</h2>
|
||||||
|
<form id="whiteboard-config-form">
|
||||||
|
<label>Render style
|
||||||
|
<select id="whiteboard_render_style">
|
||||||
|
<option value="classic" {% if not whiteboard_cfg or whiteboard_cfg.render_style == 'classic' %}selected{% endif %}>Classic (no frame)</option>
|
||||||
|
<option value="modern" {% if whiteboard_cfg and whiteboard_cfg.render_style == 'modern' %}selected{% endif %}>Modern (rounded-corner card, experimental)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
{% include "_widget_border_fields.html" %}
|
{% include "_widget_border_fields.html" %}
|
||||||
|
|
||||||
{% include "_widget_button_fields.html" %}
|
{% include "_widget_button_fields.html" %}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
{% block page_title %}Administration{% endblock %}
|
{% block page_title %}Administration{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% include "_admin_tabs.html" %}
|
||||||
|
|
||||||
{% if notice %}<div class="status ok">{{ notice }}</div>{% endif %}
|
{% if notice %}<div class="status ok">{{ notice }}</div>{% endif %}
|
||||||
{% if error %}<div class="status err">{{ error }}</div>{% endif %}
|
{% if error %}<div class="status err">{{ error }}</div>{% endif %}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{% extends "app_base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Server Logs{% endblock %}
|
||||||
|
{% block page_title %}Administration{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% include "_admin_tabs.html" %}
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="card-title-row">
|
||||||
|
<h2 class="card-title">Server Logs</h2>
|
||||||
|
<a href="/admin/logs/download" class="secondary btn-inline">Download full log</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not log_exists %}
|
||||||
|
<p class="sub">No log file yet -- nothing has been logged since this server last started.</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="sub">Last {{ log_lines }} lines of <code>{{ log_path }}</code>. Rotates at ~2MB
|
||||||
|
(older entries roll into <code>{{ log_path }}.1</code>, etc. -- not shown here; use
|
||||||
|
"Download full log" for just the current file).</p>
|
||||||
|
<div class="log-view-controls">
|
||||||
|
{% for n in [200, 500, 2000, 5000] %}
|
||||||
|
<a href="/admin/logs?lines={{ n }}" class="{% if log_lines == n %}active{% endif %}">{{ n }}</a>
|
||||||
|
{% endfor %}
|
||||||
|
<a href="/admin/logs?lines={{ log_lines }}" class="secondary btn-inline">Refresh</a>
|
||||||
|
</div>
|
||||||
|
<pre class="log-view">{{ log_text }}</pre>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -17,6 +17,14 @@
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<link rel="stylesheet" href="/static/theme.css">
|
<link rel="stylesheet" href="/static/theme.css">
|
||||||
|
<link rel="manifest" href="/static/manifest.json">
|
||||||
|
<link rel="icon" href="/static/icons/favicon.png">
|
||||||
|
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png">
|
||||||
|
<meta name="theme-color" content="#2563eb">
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="ESPresso">
|
||||||
{% block extra_head %}{% endblock %}
|
{% block extra_head %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -17,6 +17,14 @@
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<link rel="stylesheet" href="/static/theme.css">
|
<link rel="stylesheet" href="/static/theme.css">
|
||||||
|
<link rel="manifest" href="/static/manifest.json">
|
||||||
|
<link rel="icon" href="/static/icons/favicon.png">
|
||||||
|
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png">
|
||||||
|
<meta name="theme-color" content="#2563eb">
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="ESPresso">
|
||||||
{% block extra_head %}{% endblock %}
|
{% block extra_head %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -139,11 +139,12 @@
|
|||||||
|
|
||||||
<details class="card">
|
<details class="card">
|
||||||
<summary class="card-title">Advanced configuration</summary>
|
<summary class="card-title">Advanced configuration</summary>
|
||||||
<p class="sub">Color-quantization values used when dithering photos
|
<p class="sub">Color-quantization values used for every widget
|
||||||
for this panel -- approximations by default, since exact primaries
|
<strong>except photos</strong> (which has its own separate
|
||||||
aren't published. Tune them by comparing a rendered photo against
|
settings below) -- approximations by default, since exact
|
||||||
the physical panel; different panel units can vary enough to be
|
primaries aren't published. Tune them by comparing a rendered
|
||||||
worth calibrating per frame.</p>
|
widget against the physical panel; different panel units can
|
||||||
|
vary enough to be worth calibrating per frame.</p>
|
||||||
<div class="palette-table-wrap">
|
<div class="palette-table-wrap">
|
||||||
<table class="palette-table">
|
<table class="palette-table">
|
||||||
<thead><tr><th></th><th>Color</th><th>Hex</th><th>R</th><th>G</th><th>B</th></tr></thead>
|
<thead><tr><th></th><th>Color</th><th>Hex</th><th>R</th><th>G</th><th>B</th></tr></thead>
|
||||||
@@ -187,6 +188,68 @@
|
|||||||
|
|
||||||
<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 class="card">
|
||||||
|
<summary class="card-title">Theme</summary>
|
||||||
|
<p class="sub">A curated visual style for widgets using the
|
||||||
|
"modern" (experimental) render style -- font, corner radius,
|
||||||
|
shadow, and header accent. Widgets rendered in the classic
|
||||||
|
style are unaffected. Photos are unaffected too (see Photos
|
||||||
|
configuration below).</p>
|
||||||
|
<label>Theme
|
||||||
|
<select id="theme-select">
|
||||||
|
{% for key, t in themes.items() %}
|
||||||
|
<option value="{{ key }}" {% if frame.theme == key %}selected{% endif %}>{{ t.label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button type="button" class="secondary" id="theme-save" style="margin-top: 16px;">Save</button>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="card">
|
||||||
|
<summary class="card-title">Photos configuration</summary>
|
||||||
|
<p class="sub">Palette and dithering used <strong>only</strong> by
|
||||||
|
the photos widget, independent of Advanced configuration above --
|
||||||
|
lets you tune the rest of this frame's widgets (e.g. a "modern"
|
||||||
|
HTML-rendered look) without changing what looks best for actual
|
||||||
|
photographs, or vice versa.</p>
|
||||||
|
<div class="palette-table-wrap">
|
||||||
|
<table class="palette-table">
|
||||||
|
<thead><tr><th></th><th>Color</th><th>Hex</th><th>R</th><th>G</th><th>B</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% set current_photo_palette = frame.photo_palette_rgb or default_palette_rgb %}
|
||||||
|
{% set current_photo_hex = palette_to_hex(current_photo_palette) %}
|
||||||
|
{% for label in palette_labels %}
|
||||||
|
<tr>
|
||||||
|
<td><span class="photo-palette-swatch-preview" data-index="{{ loop.index0 }}"
|
||||||
|
style="background: {{ current_photo_hex[loop.index0] }};"></span></td>
|
||||||
|
<td>{{ label }}</td>
|
||||||
|
<td><input type="text" class="photo-palette-hex" id="photo_palette_{{ loop.index0 }}" data-index="{{ loop.index0 }}"
|
||||||
|
value="{{ current_photo_hex[loop.index0] }}" maxlength="7" pattern="#[0-9a-fA-F]{6}"
|
||||||
|
spellcheck="false" autocomplete="off"></td>
|
||||||
|
<td><input type="number" class="photo-palette-rgb photo-palette-r" data-index="{{ loop.index0 }}"
|
||||||
|
min="0" max="255" value="{{ current_photo_palette[loop.index0][0] }}"></td>
|
||||||
|
<td><input type="number" class="photo-palette-rgb photo-palette-g" data-index="{{ loop.index0 }}"
|
||||||
|
min="0" max="255" value="{{ current_photo_palette[loop.index0][1] }}"></td>
|
||||||
|
<td><input type="number" class="photo-palette-rgb photo-palette-b" data-index="{{ loop.index0 }}"
|
||||||
|
min="0" max="255" value="{{ current_photo_palette[loop.index0][2] }}"></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<label>Dithering strength <span class="slider-value" id="photo_dither_strength_value">{{ "%.2f" | format(frame.photo_dither_strength) }}</span>
|
||||||
|
<input type="range" id="photo_dither_strength" min="0" max="1" step="0.05" value="{{ frame.photo_dither_strength }}">
|
||||||
|
</label>
|
||||||
|
<button type="button" class="secondary" id="photo-palette-save" style="margin-top: 16px;">Save</button>
|
||||||
|
<button type="button" class="secondary" id="photo-palette-reset">Reset to defaults</button>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
@@ -221,6 +284,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,32 @@
|
|||||||
|
{% macro day_section(header, weather_entries, weather_size, unit_suffix, rows, more_count, row_h, body_size, title_size, accent_start, accent_end, header_h) %}
|
||||||
|
<div class="day-section">
|
||||||
|
{#- Fixed height (not auto) -- every stacked day-section's header
|
||||||
|
must be exactly this tall regardless of whether THIS particular
|
||||||
|
day has a weather entry, or days with/without weather misalign
|
||||||
|
where their event rows start (see calendar_week_horizontal's
|
||||||
|
identical fix/reasoning). #}
|
||||||
|
<div class="day-header" style="background: linear-gradient(135deg, {{ accent_start }} 0%, {{ accent_end }} 100%); font-size: {{ title_size }}px; height: {{ header_h }}px;">
|
||||||
|
<div class="day-header-title">{{ header }}</div>
|
||||||
|
{% if weather_entries %}
|
||||||
|
<div class="day-weather-row" style="font-size: {{ weather_size }}px;">
|
||||||
|
{% for we in weather_entries %}
|
||||||
|
<div class="day-weather-entry"><span class="day-weather-icon" style="font-size: {{ weather_size * 1.3 }}px;">{{ we.emoji }}</span><span>{{ we.high }}°/{{ we.low }}°{{ unit_suffix }}</span></div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="day-rows">
|
||||||
|
{% if not rows and not more_count %}
|
||||||
|
<div class="day-empty" style="font-size: {{ body_size }}px;">Nothing scheduled</div>
|
||||||
|
{% endif %}
|
||||||
|
{% for row in rows %}
|
||||||
|
<div class="day-row" style="height: {{ row_h }}px;">
|
||||||
|
<div class="day-chip">{% for c in row.colors %}<span style="background:{{ c }};"></span>{% endfor %}</div>
|
||||||
|
<div class="day-time" style="font-size: {{ body_size }}px;">{{ row.time }}</div>
|
||||||
|
<div class="day-summary" style="font-size: {{ body_size }}px;">{{ row.summary }}</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% if more_count %}<div class="day-more" style="font-size: {{ body_size }}px;">+{{ more_count }} more</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html><head><style>
|
||||||
|
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_regular }}"); font-weight: 400; }
|
||||||
|
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", 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: 8px;
|
||||||
|
}
|
||||||
|
.icon-wrap { display: flex; align-items: center; }
|
||||||
|
.icon-body {
|
||||||
|
width: {{ icon_w }}px;
|
||||||
|
height: {{ icon_h }}px;
|
||||||
|
border: {{ stroke }}px solid #000000;
|
||||||
|
border-radius: {{ icon_radius }}px;
|
||||||
|
padding: {{ stroke }}px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
.icon-fill {
|
||||||
|
width: {{ fill_pct }}%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: {{ fill_radius }}px;
|
||||||
|
background: linear-gradient(180deg, {{ fill_color }}, {{ fill_color_dark }});
|
||||||
|
}
|
||||||
|
.icon-nub {
|
||||||
|
width: {{ nub_w }}px;
|
||||||
|
height: {{ nub_h }}px;
|
||||||
|
background: #000000;
|
||||||
|
border-radius: 0 {{ nub_radius }}px {{ nub_radius }}px 0;
|
||||||
|
}
|
||||||
|
.pct { font-weight: 700; font-size: {{ pct_size }}px; line-height: 1; color: {{ fill_color }}; }
|
||||||
|
.line { font-weight: 400; font-size: {{ line_size }}px; line-height: 1; color: #5b6674; }
|
||||||
|
</style></head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="icon-wrap">
|
||||||
|
<div class="icon-body"><div class="icon-fill"></div></div>
|
||||||
|
<div class="icon-nub"></div>
|
||||||
|
</div>
|
||||||
|
<div class="pct">{{ percent }}%</div>
|
||||||
|
{% for line in lines %}<div class="line">{{ line }}</div>{% endfor %}
|
||||||
|
</div>
|
||||||
|
</body></html>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html><head><style>
|
||||||
|
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_regular }}"); font-weight: 400; }
|
||||||
|
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", 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;
|
||||||
|
{% if shadow %}box-shadow: 0 4px 14px rgba(0, 20, 60, 0.2);{% endif %}
|
||||||
|
background: #ffffff;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.day-section { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; }
|
||||||
|
.day-header { flex: 0 0 auto; color: #ffffff; font-weight: 700; line-height: 1.2; padding: 10px 16px; overflow: hidden; }
|
||||||
|
.day-weather-row { display: flex; gap: 14px; margin-top: 6px; }
|
||||||
|
.day-weather-entry { display: flex; align-items: center; gap: 4px; color: rgba(255,255,255,0.9); }
|
||||||
|
.day-weather-icon { line-height: 1; }
|
||||||
|
.day-rows { flex: 1 1 auto; padding: 8px 14px; overflow: hidden; }
|
||||||
|
.day-row { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.day-chip { display: flex; height: 12px; width: 10px; border-radius: 3px; overflow: hidden; flex: 0 0 auto; }
|
||||||
|
.day-chip span { flex: 1 1 0; }
|
||||||
|
.day-time { color: #5b6674; flex: 0 0 auto; white-space: nowrap; }
|
||||||
|
.day-summary { color: #17233b; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.day-empty, .day-more { color: #5b6674; padding-top: 4px; }
|
||||||
|
</style></head>
|
||||||
|
<body>
|
||||||
|
{% import "_calendar_day_section.html.jinja" as ds %}
|
||||||
|
<div class="card">
|
||||||
|
{{ ds.day_section(header, weather_entries, weather_size, unit_suffix, rows, more_count, row_h, body_size, title_size, accent_start, accent_end, header_h) }}
|
||||||
|
</div>
|
||||||
|
</body></html>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html><head><style>
|
||||||
|
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_regular }}"); font-weight: 400; }
|
||||||
|
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", 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;
|
||||||
|
{% if shadow %}box-shadow: 0 4px 14px rgba(0, 20, 60, 0.2);{% endif %}
|
||||||
|
background: #ffffff;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.weekday-row { display: flex; flex: 0 0 auto; background: {{ accent_start }}; }
|
||||||
|
.weekday-cell { flex: 1 1 0; color: #ffffff; font-weight: 700; font-size: {{ header_size }}px; text-align: center; padding: 4px 0; }
|
||||||
|
.weeks { flex: 1 1 auto; display: flex; flex-direction: column; }
|
||||||
|
.week-row { flex: 1 1 0; display: flex; }
|
||||||
|
.day-cell { flex: 1 1 0; border: 1px solid #e2e6ec; padding: 4px; min-width: 0; overflow: hidden; }
|
||||||
|
{#- Bold everywhere, including out-of-month -- de-emphasis is via
|
||||||
|
smaller size only, not weight or a gray color. Regular-weight and
|
||||||
|
gray text are both individually fragile under Bayer ordered
|
||||||
|
dithering at small sizes (thin/low-contrast anti-aliased edges
|
||||||
|
have little "mass" to survive the bias+threshold step), and this
|
||||||
|
cell combined both, which degraded out-of-month day numbers into
|
||||||
|
unrecognizable speckle -- classic PIL's own de-emphasis trick
|
||||||
|
(weight instead of gray, see calendar_render._build_month's
|
||||||
|
docstring) doesn't transfer safely to this render path. #}
|
||||||
|
.day-num { font-size: {{ day_size }}px; font-weight: 700; color: #17233b; }
|
||||||
|
.day-num.out-of-month { font-size: {{ day_size * 0.8 }}px; }
|
||||||
|
.day-num.today {
|
||||||
|
display: inline-block; background: {{ accent_start }}; color: #ffffff;
|
||||||
|
border-radius: 4px; padding: 0 4px;
|
||||||
|
}
|
||||||
|
.dots { display: flex; gap: 3px; margin-top: 3px; align-items: center; flex-wrap: wrap; }
|
||||||
|
.dot { width: {{ dot_size }}px; height: {{ dot_size }}px; border-radius: 50%; flex: 0 0 auto; }
|
||||||
|
.dot-more { font-size: {{ day_size * 0.8 }}px; color: #5b6674; }
|
||||||
|
</style></head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="weekday-row">
|
||||||
|
{% for name in day_names %}<div class="weekday-cell">{{ name }}</div>{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="weeks">
|
||||||
|
{% for week in weeks %}
|
||||||
|
<div class="week-row">
|
||||||
|
{% for day in week %}
|
||||||
|
<div class="day-cell">
|
||||||
|
<span class="day-num {% if day.is_today %}today{% elif not day.in_month %}out-of-month{% endif %}">{{ day.day_num }}</span>
|
||||||
|
{% if day.dots %}
|
||||||
|
<div class="dots">
|
||||||
|
{% for c in day.dots %}<div class="dot" style="background:{{ c }};"></div>{% endfor %}
|
||||||
|
{% if day.more_count %}<span class="dot-more">+{{ day.more_count }}</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body></html>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html><head><style>
|
||||||
|
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_regular }}"); font-weight: 400; }
|
||||||
|
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", 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;
|
||||||
|
{% if shadow %}box-shadow: 0 4px 14px rgba(0, 20, 60, 0.2);{% endif %}
|
||||||
|
background: #ffffff;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.day-section { display: flex; flex-direction: column; flex: 1 1 0; min-height: 0; overflow: hidden; }
|
||||||
|
.day-section + .day-section { border-top: 1px solid #e2e6ec; }
|
||||||
|
.day-header { flex: 0 0 auto; color: #ffffff; font-weight: 700; line-height: 1.2; padding: 8px 16px; overflow: hidden; }
|
||||||
|
.day-weather-row { display: flex; gap: 14px; margin-top: 4px; }
|
||||||
|
.day-weather-entry { display: flex; align-items: center; gap: 4px; color: rgba(255,255,255,0.9); }
|
||||||
|
.day-weather-icon { line-height: 1; }
|
||||||
|
.day-rows { flex: 1 1 auto; padding: 6px 14px; overflow: hidden; }
|
||||||
|
.day-row { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.day-chip { display: flex; height: 12px; width: 10px; border-radius: 3px; overflow: hidden; flex: 0 0 auto; }
|
||||||
|
.day-chip span { flex: 1 1 0; }
|
||||||
|
.day-time { color: #5b6674; flex: 0 0 auto; white-space: nowrap; }
|
||||||
|
.day-summary { color: #17233b; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.day-empty, .day-more { color: #5b6674; padding-top: 2px; }
|
||||||
|
</style></head>
|
||||||
|
<body>
|
||||||
|
{% import "_calendar_day_section.html.jinja" as ds %}
|
||||||
|
<div class="card">
|
||||||
|
{% for day in days %}
|
||||||
|
{{ ds.day_section(day.header, day.weather_entries, weather_size, unit_suffix, day.rows, day.more_count, row_h, body_size, title_size, accent_start, accent_end, header_h) }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</body></html>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html><head><style>
|
||||||
|
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_regular }}"); font-weight: 400; }
|
||||||
|
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", 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;
|
||||||
|
{% if shadow %}box-shadow: 0 4px 14px rgba(0, 20, 60, 0.2);{% endif %}
|
||||||
|
background: #ffffff;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.col { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; }
|
||||||
|
.col + .col { border-left: 1px solid #e2e6ec; }
|
||||||
|
.col-header {
|
||||||
|
/* Fixed height (not auto) -- every column must be exactly this tall
|
||||||
|
regardless of whether THIS particular day has a weather entry, or
|
||||||
|
columns with/without weather misalign their event rows to
|
||||||
|
different starting Y positions across the week grid. */
|
||||||
|
height: {{ header_h }}px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
background: linear-gradient(135deg, {{ accent_start }} 0%, {{ accent_end }} 100%);
|
||||||
|
color: #ffffff; font-weight: 700; font-size: {{ header_size }}px;
|
||||||
|
padding: 6px 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.col-header .label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.col-weather { display: flex; align-items: center; gap: 3px; color: rgba(255,255,255,0.9); font-size: {{ weather_size }}px; margin-top: 2px; }
|
||||||
|
.col-rows { flex: 1 1 auto; padding: 4px; overflow: hidden; }
|
||||||
|
.col-row { display: flex; align-items: center; gap: 4px; min-width: 0; }
|
||||||
|
.col-chip { width: 7px; height: 7px; border-radius: 2px; flex: 0 0 auto; }
|
||||||
|
.col-summary {
|
||||||
|
flex: 1 1 0; min-width: 0;
|
||||||
|
font-size: {{ chip_size }}px; color: #17233b; line-height: 1.3;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.col-more { font-size: {{ chip_size }}px; color: #5b6674; }
|
||||||
|
</style></head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
{% for col in cols %}
|
||||||
|
<div class="col">
|
||||||
|
<div class="col-header">
|
||||||
|
<div class="label">{{ col.label }}</div>
|
||||||
|
{% if col.weather %}<div class="col-weather"><span>{{ col.weather.emoji }}</span><span>{{ col.weather.high }}°/{{ col.weather.low }}°{{ unit_suffix }}</span></div>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="col-rows">
|
||||||
|
{% for row in col.rows %}
|
||||||
|
<div class="col-row"><div class="col-chip" style="background:{{ row.color }};"></div><div class="col-summary">{{ row.summary }}</div></div>
|
||||||
|
{% endfor %}
|
||||||
|
{% if col.more_count %}<div class="col-more">+{{ col.more_count }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</body></html>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html><head><style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
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;
|
||||||
|
{% if shadow %}box-shadow: 0 4px 14px rgba(0, 20, 60, 0.25);{% endif %}
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
.card img {
|
||||||
|
width: 100%; height: 100%; display: block;
|
||||||
|
/* "contain", not "cover" -- the source image already went through
|
||||||
|
compose_into's own crop/fit (e.g. whiteboard's deliberate
|
||||||
|
letterbox-never-crop mode), so this card must not re-crop it;
|
||||||
|
the gutter inset is small relative to typical widget sizes, so
|
||||||
|
"contain" leaves at most a sliver of background visible, not a
|
||||||
|
real letterbox. */
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
</style></head>
|
||||||
|
<body>
|
||||||
|
<div class="card"><img src="data:image/png;base64,{{ image_b64 }}"></div>
|
||||||
|
</body></html>
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html><head><style>
|
||||||
|
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_regular }}"); font-weight: 400; }
|
||||||
|
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", 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;
|
||||||
|
{% if shadow %}box-shadow: 0 4px 14px rgba(0, 20, 60, 0.2);{% endif %}
|
||||||
|
background: #ffffff;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
height: {{ header_h }}px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
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; line-height: 1; }
|
||||||
|
.rows { flex: 1 1 auto; padding: 8px 14px; overflow: hidden; }
|
||||||
|
.row { display: flex; align-items: center; gap: 8px; height: {{ row_h }}px; }
|
||||||
|
.chip { display: flex; height: 12px; width: 10px; border-radius: 3px; overflow: hidden; flex: 0 0 auto; }
|
||||||
|
.chip span { flex: 1 1 0; }
|
||||||
|
.box {
|
||||||
|
width: {{ box_size }}px; height: {{ box_size }}px; border-radius: 3px; flex: 0 0 auto;
|
||||||
|
border: 2px solid #17233b;
|
||||||
|
}
|
||||||
|
.box.done { border-color: {{ accent_start }}; background: {{ accent_start }}; }
|
||||||
|
.due { font-size: {{ body_size }}px; color: #5b6674; flex: 0 0 auto; white-space: nowrap; }
|
||||||
|
.summary {
|
||||||
|
font-size: {{ body_size }}px; color: #17233b; line-height: 1.2;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.empty { font-size: {{ body_size }}px; color: #5b6674; padding-top: 4px; }
|
||||||
|
.more { font-size: {{ body_size }}px; color: #5b6674; padding-top: 2px; }
|
||||||
|
</style></head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="header"><div class="title">{{ title }}</div></div>
|
||||||
|
<div class="rows">
|
||||||
|
{% if not rows %}
|
||||||
|
<div class="empty">Nothing outstanding</div>
|
||||||
|
{% endif %}
|
||||||
|
{% for row in rows %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="chip">{% for c in row.colors %}<span style="background:{{ c }};"></span>{% endfor %}</div>
|
||||||
|
<div class="box {% if row.done %}done{% endif %}"></div>
|
||||||
|
{% if row.due %}<div class="due">{{ row.due }}</div>{% endif %}
|
||||||
|
<div class="summary">{{ row.summary }}</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% if more_count %}<div class="more">+{{ more_count }} more</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body></html>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html><head><style>
|
||||||
|
@font-face { font-family: "TextFont"; src: url("file://{{ font_regular }}"); font-weight: 400; font-style: normal; }
|
||||||
|
@font-face { font-family: "TextFont"; src: url("file://{{ font_bold }}"); font-weight: 700; font-style: normal; }
|
||||||
|
@font-face { font-family: "TextFont"; src: url("file://{{ font_italic }}"); font-weight: 400; font-style: italic; }
|
||||||
|
@font-face { font-family: "TextFont"; src: url("file://{{ font_bold_italic }}"); font-weight: 700; font-style: italic; }
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { width: {{ w }}px; height: {{ h }}px; background: {{ bg_color }}; }
|
||||||
|
.wrap {
|
||||||
|
width: {{ w - margin * 2 }}px;
|
||||||
|
min-height: {{ h - margin * 2 }}px;
|
||||||
|
margin: {{ margin }}px;
|
||||||
|
font-family: "TextFont", sans-serif;
|
||||||
|
font-size: {{ size }}px;
|
||||||
|
line-height: {{ line_height }};
|
||||||
|
text-align: {{ align }};
|
||||||
|
color: #000000;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
p { min-height: 1em; }
|
||||||
|
</style></head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
{% for paragraph in paragraphs %}
|
||||||
|
<p>
|
||||||
|
{% if not paragraph %} {% endif %}
|
||||||
|
{% for run in paragraph %}<span style="{% if run.bold %}font-weight:700;{% endif %}{% if run.italic %}font-style:italic;{% endif %}{% if run.underline %}text-decoration:underline;{% endif %}{% if run.color %}color:{{ run.color }};{% endif %}{% if run.bg %}background:{{ run.bg }};{% endif %}">{{ run.text }}</span>{% endfor %}
|
||||||
|
</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</body></html>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html><head><style>
|
||||||
|
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_regular }}"); font-weight: 400; }
|
||||||
|
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", 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: "ThemeFont"; src: url("file://{{ font_regular }}"); font-weight: 400; }
|
||||||
|
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", 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;
|
||||||
|
{% if shadow %}box-shadow: 0 4px 14px rgba(0, 20, 60, 0.25);{% endif %}
|
||||||
|
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>
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
"""Curated theme presets for "modern" (HTML/CSS) style widgets, plus the
|
||||||
|
font-family table every modern-style widget -- and widgets/text.py's own
|
||||||
|
classic PIL path -- resolves fonts through. The font table used to be
|
||||||
|
widgets/text.py's private property; it's hoisted here (text.py now
|
||||||
|
imports it back) since html_render.py's build_text/build_daily/etc. all
|
||||||
|
need to resolve a theme's font_family to real font files too, not just
|
||||||
|
the text widget.
|
||||||
|
|
||||||
|
Inspired by Tesserae's (github.com/dmellok/tesserae, AGPL-3.0) three-
|
||||||
|
layer CSS custom-property theme system -- primitives, semantic per-
|
||||||
|
theme tokens, component tokens -- reimplemented here as original Python/
|
||||||
|
CSS rather than copied (see docs/widgets.md and this repo's CLAUDE.md on
|
||||||
|
copyleft dependencies).
|
||||||
|
|
||||||
|
A theme is purely **stylistic**: font family, corner radius, drop
|
||||||
|
shadow, header gradient on/off, and an accent hue for the widgets that
|
||||||
|
have an actual header/accent region to dither richer (see
|
||||||
|
html_render.ordered_dither_regions). It never touches **functional**
|
||||||
|
color-coding -- battery's charge-level red/yellow/green, calendar/tasks'
|
||||||
|
per-owner event color chips, and text's user-authored inline run colors
|
||||||
|
are status/identity signals, not style choices, and no theme may
|
||||||
|
recolor them.
|
||||||
|
|
||||||
|
Rich accent_hex values are not restricted to the 6 exact panel inks --
|
||||||
|
`ordered_dither_regions` approximates them via denser Bayer stippling in
|
||||||
|
just the accent region (verified directly: terracotta/ochre/moss/teal/
|
||||||
|
slate-blue/plum swatches all resolve to a believable multi-ink
|
||||||
|
approximation at amplitude ~130, the same mechanism -- spatial
|
||||||
|
dithering, not flat quantization -- Tesserae's own calibrated-palette
|
||||||
|
Floyd-Steinberg uses, just ordered instead of diffused)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PIL import ImageFont
|
||||||
|
|
||||||
|
from . import panel_style
|
||||||
|
|
||||||
|
_FONT_DIR = Path(__file__).resolve().parent / "fonts"
|
||||||
|
|
||||||
|
# --- Font family table (hoisted from widgets/text.py) -----------------
|
||||||
|
|
||||||
|
DEFAULT_FONT_FAMILY = "sans"
|
||||||
|
FONT_FAMILIES: dict[str, str] = {
|
||||||
|
"sans": "Sans-serif (Noto Sans)",
|
||||||
|
"inter": "Inter",
|
||||||
|
"source_sans": "Source Sans",
|
||||||
|
"serif": "Serif (Noto Serif)",
|
||||||
|
"elegant": "Elegant serif (Crimson Text)",
|
||||||
|
"slab": "Slab serif (Arvo)",
|
||||||
|
"mono": "Monospace (IBM Plex Mono)",
|
||||||
|
}
|
||||||
|
_FONT_FILES = {
|
||||||
|
"sans": {
|
||||||
|
(False, False): "NotoSans-Regular.ttf", (True, False): "NotoSans-Bold.ttf",
|
||||||
|
(False, True): "NotoSans-Italic.ttf", (True, True): "NotoSans-BoldItalic.ttf",
|
||||||
|
},
|
||||||
|
"inter": {
|
||||||
|
(False, False): "Inter-Regular.ttf", (True, False): "Inter-Bold.ttf",
|
||||||
|
(False, True): "Inter-Italic.ttf", (True, True): "Inter-BoldItalic.ttf",
|
||||||
|
},
|
||||||
|
"source_sans": {
|
||||||
|
(False, False): "SourceSans3-Regular.ttf", (True, False): "SourceSans3-Bold.ttf",
|
||||||
|
(False, True): "SourceSans3-Italic.ttf", (True, True): "SourceSans3-BoldItalic.ttf",
|
||||||
|
},
|
||||||
|
"serif": {
|
||||||
|
(False, False): "NotoSerif-Regular.ttf", (True, False): "NotoSerif-Bold.ttf",
|
||||||
|
(False, True): "NotoSerif-Italic.ttf", (True, True): "NotoSerif-BoldItalic.ttf",
|
||||||
|
},
|
||||||
|
"elegant": {
|
||||||
|
(False, False): "CrimsonText-Regular.ttf", (True, False): "CrimsonText-Bold.ttf",
|
||||||
|
(False, True): "CrimsonText-Italic.ttf", (True, True): "CrimsonText-BoldItalic.ttf",
|
||||||
|
},
|
||||||
|
"slab": {
|
||||||
|
(False, False): "Arvo-Regular.ttf", (True, False): "Arvo-Bold.ttf",
|
||||||
|
(False, True): "Arvo-Italic.ttf", (True, True): "Arvo-BoldItalic.ttf",
|
||||||
|
},
|
||||||
|
"mono": {
|
||||||
|
(False, False): "IBMPlexMono-Regular.ttf", (True, False): "IBMPlexMono-Bold.ttf",
|
||||||
|
(False, True): "IBMPlexMono-Italic.ttf", (True, True): "IBMPlexMono-BoldItalic.ttf",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def font_path(family: str, bold: bool, italic: bool) -> Path:
|
||||||
|
files = _FONT_FILES.get(family) or _FONT_FILES[DEFAULT_FONT_FAMILY]
|
||||||
|
return _FONT_DIR / files[(bold, italic)]
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=256)
|
||||||
|
def font(family: str, bold: bool, italic: bool, size: int) -> ImageFont.FreeTypeFont:
|
||||||
|
return ImageFont.truetype(str(font_path(family, bold, italic)), size)
|
||||||
|
|
||||||
|
|
||||||
|
def _rgb_to_hex(rgb: tuple[int, int, int]) -> str:
|
||||||
|
return "#%02x%02x%02x" % tuple(rgb)
|
||||||
|
|
||||||
|
|
||||||
|
def _darken_hex(rgb: tuple[int, int, int], factor: float = 0.75) -> str:
|
||||||
|
return _rgb_to_hex(tuple(max(0, round(c * factor)) for c in rgb))
|
||||||
|
|
||||||
|
|
||||||
|
# --- Theme presets -------------------------------------------------------
|
||||||
|
|
||||||
|
DEFAULT_THEME = "classic"
|
||||||
|
|
||||||
|
# accent_hex=None means "keep each widget's own classic THEME_* ink" --
|
||||||
|
# resolve_theme() below is what actually looks that up -- so "classic" is
|
||||||
|
# deliberately a no-visual-change default, byte-identical to how modern
|
||||||
|
# style already rendered before this theme system existed.
|
||||||
|
THEMES: dict[str, dict] = {
|
||||||
|
"classic": {
|
||||||
|
"label": "Classic", "accent_hex": None,
|
||||||
|
"font_family": "inter", "radius": panel_style.CARD_RADIUS, "shadow": True,
|
||||||
|
"gradient": True, "accent_amplitude": 48.0,
|
||||||
|
},
|
||||||
|
"terracotta": {
|
||||||
|
"label": "Terracotta", "accent_hex": "#a84b2a",
|
||||||
|
"font_family": "inter", "radius": panel_style.CARD_RADIUS, "shadow": True,
|
||||||
|
"gradient": True, "accent_amplitude": 130.0,
|
||||||
|
},
|
||||||
|
"ochre": {
|
||||||
|
"label": "Ochre", "accent_hex": "#b8892b",
|
||||||
|
"font_family": "slab", "radius": panel_style.CARD_RADIUS, "shadow": True,
|
||||||
|
"gradient": True, "accent_amplitude": 130.0,
|
||||||
|
},
|
||||||
|
"moss": {
|
||||||
|
"label": "Moss", "accent_hex": "#4f6f36",
|
||||||
|
"font_family": "serif", "radius": 4, "shadow": False,
|
||||||
|
"gradient": False, "accent_amplitude": 130.0,
|
||||||
|
},
|
||||||
|
"teal": {
|
||||||
|
"label": "Teal", "accent_hex": "#2b7a78",
|
||||||
|
"font_family": "source_sans", "radius": panel_style.CARD_RADIUS, "shadow": True,
|
||||||
|
"gradient": True, "accent_amplitude": 130.0,
|
||||||
|
},
|
||||||
|
"slate": {
|
||||||
|
"label": "Slate", "accent_hex": "#3f5a88",
|
||||||
|
"font_family": "inter", "radius": 0, "shadow": False,
|
||||||
|
"gradient": False, "accent_amplitude": 130.0,
|
||||||
|
},
|
||||||
|
"plum": {
|
||||||
|
"label": "Plum", "accent_hex": "#6a3b5e",
|
||||||
|
"font_family": "elegant", "radius": panel_style.CARD_RADIUS, "shadow": True,
|
||||||
|
"gradient": False, "accent_amplitude": 130.0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# "classic" theme's accent_hex is None, meaning "keep this specific
|
||||||
|
# widget kind's own pre-theme-system look exactly" -- for tasks/calendar
|
||||||
|
# that's their classic THEME_TASKS/THEME_CALENDAR ink (a flat color, both
|
||||||
|
# gradient stops equal, since neither ever had a gradient header before
|
||||||
|
# this system existed); weather's modern style never went through
|
||||||
|
# panel_style.THEME at all -- its header was always this fixed blue
|
||||||
|
# gradient (see html_render.py's now-removed ACCENT_START/ACCENT_END
|
||||||
|
# constants) -- preserved here byte-for-byte so "classic" stays a
|
||||||
|
# genuinely no-visual-change default for every widget kind that shipped
|
||||||
|
# before themes existed.
|
||||||
|
_CLASSIC_WEATHER_GRADIENT = ("#1c4fd6", "#6fa8ff")
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_theme(theme_name: str | None, widget_kind: str, palette_rgb: list | None) -> dict:
|
||||||
|
"""Concrete, ready-to-render values for one widget's modern-style
|
||||||
|
build_* function: accent_hex/accent_hex_dark (a gradient's two CSS
|
||||||
|
stops -- both equal when the theme has no gradient), font_family
|
||||||
|
(validated against FONT_FAMILIES) plus its resolved font_regular/
|
||||||
|
font_bold file paths, radius, shadow, gradient, and accent_amplitude
|
||||||
|
(for ordered_dither_regions' header rect -- unused by widgets that
|
||||||
|
dither their header at the base amplitude only, see module
|
||||||
|
docstring). widget_kind is one of panel_style.THEME's keys
|
||||||
|
("calendar"/"tasks"/"weather") plus "battery"/"text"/"static"/
|
||||||
|
"whiteboard" for the widgets that have no classic THEME_* ink of
|
||||||
|
their own -- those fall back to BLACK when theme_name is "classic"
|
||||||
|
or unrecognized."""
|
||||||
|
theme = THEMES.get(theme_name or DEFAULT_THEME, THEMES[DEFAULT_THEME])
|
||||||
|
accent_hex = theme["accent_hex"]
|
||||||
|
if accent_hex is None:
|
||||||
|
if widget_kind == "weather":
|
||||||
|
accent_hex, accent_hex_dark = _CLASSIC_WEATHER_GRADIENT
|
||||||
|
else:
|
||||||
|
ink_index = panel_style.THEME.get(widget_kind, panel_style.BLACK)
|
||||||
|
accent_hex = accent_hex_dark = _rgb_to_hex(panel_style.ink(palette_rgb, ink_index))
|
||||||
|
else:
|
||||||
|
accent_hex_dark = accent_hex if not theme["gradient"] else _darken_hex(
|
||||||
|
tuple(int(accent_hex[i:i + 2], 16) for i in (1, 3, 5))
|
||||||
|
)
|
||||||
|
family = theme["font_family"] if theme["font_family"] in FONT_FAMILIES else DEFAULT_FONT_FAMILY
|
||||||
|
return {
|
||||||
|
"theme_name": theme_name if theme_name in THEMES else DEFAULT_THEME,
|
||||||
|
"accent_hex": accent_hex,
|
||||||
|
"accent_hex_dark": accent_hex_dark,
|
||||||
|
"font_family": family,
|
||||||
|
"font_regular": str(font_path(family, False, False)),
|
||||||
|
"font_bold": str(font_path(family, True, False)),
|
||||||
|
"radius": theme["radius"],
|
||||||
|
"shadow": theme["shadow"],
|
||||||
|
"gradient": theme["gradient"],
|
||||||
|
"accent_amplitude": theme["accent_amplitude"],
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
@@ -88,6 +60,21 @@ def _format_age(as_of: float) -> str:
|
|||||||
return f"{round(delta / 86400)}d ago"
|
return f"{round(delta / 86400)}d ago"
|
||||||
|
|
||||||
|
|
||||||
|
def _lines_for(mode: str, frame: Frame, db: Session) -> list[str]:
|
||||||
|
"""The 0-2 caption lines "detailed" mode shows below the percent --
|
||||||
|
shared by both render styles so the estimate/age formatting only
|
||||||
|
lives in one place."""
|
||||||
|
if mode != "detailed":
|
||||||
|
return []
|
||||||
|
lines = []
|
||||||
|
estimate_s = battery_estimate_s(frame, db)
|
||||||
|
if estimate_s is not None:
|
||||||
|
lines.append(_format_estimate(estimate_s))
|
||||||
|
if frame.battery_as_of:
|
||||||
|
lines.append(f"Reported {_format_age(frame.battery_as_of)}")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: int,
|
def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: int,
|
||||||
is_normal_wake: bool = True) -> Image.Image:
|
is_normal_wake: bool = True) -> Image.Image:
|
||||||
"""is_normal_wake is unused -- see app/widgets/whiteboard.py's
|
"""is_normal_wake is unused -- see app/widgets/whiteboard.py's
|
||||||
@@ -99,39 +86,45 @@ 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
|
||||||
|
lines = _lines_for(mode, frame, db)
|
||||||
|
|
||||||
img = Image.new("RGB", (target_w, target_h), BG)
|
if cfg and cfg.render_style == "modern":
|
||||||
draw = ImageDraw.Draw(img)
|
# Local import: html_render pulls in Playwright, a real headless-
|
||||||
cx = target_w // 2
|
# Chromium dependency -- every other widget type, and this one's
|
||||||
|
# own classic path, should never pay for it (same reasoning as
|
||||||
|
# image_pipeline.render_placeholder's local `import qrcode`).
|
||||||
|
from .. import html_render
|
||||||
|
|
||||||
icon_h = max(20, min(target_w, target_h) // 3)
|
return html_render.build_battery(percent, lines, target_w, target_h, palette_rgb, frame.theme)
|
||||||
|
|
||||||
|
img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h)
|
||||||
|
cx = cx0 + cw // 2
|
||||||
|
|
||||||
|
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":
|
|
||||||
lines = []
|
|
||||||
estimate_s = battery_estimate_s(frame, db)
|
|
||||||
if estimate_s is not None:
|
|
||||||
lines.append(_format_estimate(estimate_s))
|
|
||||||
if 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
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ from __future__ import annotations
|
|||||||
from PIL import Image
|
from PIL import Image
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from ..calendar_render import _build
|
from ..calendar_render import _build
|
||||||
from ..db import widget_locked
|
from ..db import widget_locked
|
||||||
from ..models import CalendarWidgetConfig, Frame, Widget
|
from ..models import CalendarWidgetConfig, Frame, Widget
|
||||||
@@ -47,6 +49,19 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i
|
|||||||
events, fetch_summary = get_or_refresh_calendar_events_for_widget(db, frame, widget)
|
events, fetch_summary = get_or_refresh_calendar_events_for_widget(db, frame, widget)
|
||||||
weather_cities = get_or_refresh_weather_for_widget(db, frame, widget) if cfg.weather_enabled else None
|
weather_cities = get_or_refresh_weather_for_widget(db, frame, widget) if cfg.weather_enabled else None
|
||||||
|
|
||||||
|
if cfg.render_style == "modern":
|
||||||
|
# Local import: html_render pulls in Playwright, a real headless-
|
||||||
|
# Chromium dependency -- every other widget type, and this one's
|
||||||
|
# own classic path, should never pay for it.
|
||||||
|
from .. import calendar_html_render
|
||||||
|
|
||||||
|
tz = ZoneInfo(frame.timezone) if frame.timezone else ZoneInfo("UTC")
|
||||||
|
return calendar_html_render.build(
|
||||||
|
events, cfg.view, cfg.browse_offset, target_w, target_h, tz, cfg.week_start, frame.palette_rgb,
|
||||||
|
weather_cities, cfg.weather_units, cfg.week_days, cfg.week_layout, cfg.week_start_offset,
|
||||||
|
frame.theme,
|
||||||
|
)
|
||||||
|
|
||||||
return _build(
|
return _build(
|
||||||
events, view=cfg.view, browse_offset=cfg.browse_offset, target_w=target_w, target_h=target_h,
|
events, view=cfg.view, browse_offset=cfg.browse_offset, target_w=target_w, target_h=target_h,
|
||||||
timezone=frame.timezone, fetch_summary=fetch_summary, week_start=cfg.week_start,
|
timezone=frame.timezone, fetch_summary=fetch_summary, week_start=cfg.week_start,
|
||||||
|
|||||||
@@ -8,7 +8,23 @@ take down the whole panel's render just because one region out of
|
|||||||
several couldn't be composed this cycle; it falls back to a small
|
several couldn't be composed this cycle; it falls back to a small
|
||||||
placeholder instead, the same resilience calendar mode's old photo-inlay
|
placeholder instead, the same resilience calendar mode's old photo-inlay
|
||||||
already had (see routers/device.py's `except HTTPException: pass` around
|
already had (see routers/device.py's `except HTTPException: pass` around
|
||||||
its own inlay fetch)."""
|
its own inlay fetch).
|
||||||
|
|
||||||
|
Unlike every other widget type, render() quantizes its own output
|
||||||
|
(against Frame.photo_palette_rgb/photo_dither_strength, not the main
|
||||||
|
palette_rgb/dither_strength the rest of the frame uses) before
|
||||||
|
returning, so a frame can tune its other widgets' look (e.g. the
|
||||||
|
"modern" HTML-rendered widgets' Bayer dithering) independently of
|
||||||
|
whatever looks best for actual photographs -- see image_pipeline.
|
||||||
|
render_panel's docstring for why this is safe to do per-widget without
|
||||||
|
a shared-canvas seam risk. One small, accepted edge case: widget
|
||||||
|
borders are always drawn afterward (routers/device.py's
|
||||||
|
_render_one_widget) against the *main* palette_rgb, so a border on a
|
||||||
|
photos widget whose photo_palette_rgb genuinely diverges from
|
||||||
|
palette_rgb can sit against already-quantized-to-a-different-reference
|
||||||
|
photo pixels -- cosmetically arguable, not a bug, and not worth
|
||||||
|
special-casing border resolution for what's a deliberate, uncommon
|
||||||
|
customization."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -18,7 +34,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from .. import photo_queue, quiet_hours
|
from .. import photo_queue, quiet_hours
|
||||||
from ..db import widget_locked
|
from ..db import widget_locked
|
||||||
from ..image_pipeline import compose_into
|
from ..image_pipeline import _quantize, compose_into
|
||||||
from ..models import Frame, PhotoWidgetConfig, Widget
|
from ..models import Frame, PhotoWidgetConfig, Widget
|
||||||
from ..routers.common import fetch_source_and_faces, immich_client_for, list_assets
|
from ..routers.common import fetch_source_and_faces, immich_client_for, list_assets
|
||||||
from ._shared import placeholder_image
|
from ._shared import placeholder_image
|
||||||
@@ -50,7 +66,8 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i
|
|||||||
except HTTPException as e:
|
except HTTPException as e:
|
||||||
return placeholder_image(target_w, target_h, ["Photos widget", str(e.detail)[:40]])
|
return placeholder_image(target_w, target_h, ["Photos widget", str(e.detail)[:40]])
|
||||||
|
|
||||||
return compose_into(source, faces, target_w, target_h, cfg.display_mode)
|
composed = compose_into(source, faces, target_w, target_h, cfg.display_mode)
|
||||||
|
return _quantize(composed, frame.photo_palette_rgb, frame.photo_dither_strength).convert("RGB")
|
||||||
|
|
||||||
|
|
||||||
def _advance(db: Session, frame: Frame, widget: Widget) -> None:
|
def _advance(db: Session, frame: Frame, widget: Widget) -> None:
|
||||||
|
|||||||
@@ -32,4 +32,12 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i
|
|||||||
if not cfg.image:
|
if not cfg.image:
|
||||||
return placeholder_image(target_w, target_h, ["Static image widget", "not configured yet"])
|
return placeholder_image(target_w, target_h, ["Static image widget", "not configured yet"])
|
||||||
source = Image.open(io.BytesIO(cfg.image)).convert("RGB")
|
source = Image.open(io.BytesIO(cfg.image)).convert("RGB")
|
||||||
return compose_into(source, faces=None, target_w=target_w, target_h=target_h, display_mode=cfg.display_mode)
|
composed = compose_into(source, faces=None, target_w=target_w, target_h=target_h, display_mode=cfg.display_mode)
|
||||||
|
if cfg.render_style == "modern":
|
||||||
|
# Local import: html_render pulls in Playwright, a real headless-
|
||||||
|
# Chromium dependency -- every other widget type, and this one's
|
||||||
|
# own classic path, should never pay for it.
|
||||||
|
from .. import html_render
|
||||||
|
|
||||||
|
return html_render.build_framed_image(composed, target_w, target_h, frame.palette_rgb, frame.theme, "static")
|
||||||
|
return composed
|
||||||
|
|||||||
@@ -36,7 +36,15 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i
|
|||||||
signature regardless of which ones actually care."""
|
signature regardless of which ones actually care."""
|
||||||
tasks = get_or_refresh_tasks_for_widget(db, frame, widget)
|
tasks = get_or_refresh_tasks_for_widget(db, frame, widget)
|
||||||
cfg = db.get(TaskWidgetConfig, widget.id)
|
cfg = db.get(TaskWidgetConfig, widget.id)
|
||||||
return _build_tasks(tasks, target_w, target_h, frame.palette_rgb, cfg.name or "Tasks")
|
title = cfg.name or "Tasks"
|
||||||
|
if cfg.render_style == "modern":
|
||||||
|
# Local import: html_render pulls in Playwright, a real headless-
|
||||||
|
# Chromium dependency -- every other widget type, and this one's
|
||||||
|
# own classic path, should never pay for it.
|
||||||
|
from .. import html_render
|
||||||
|
|
||||||
|
return html_render.build_tasks(tasks, target_w, target_h, frame.palette_rgb, title, frame.theme)
|
||||||
|
return _build_tasks(tasks, target_w, target_h, frame.palette_rgb, title)
|
||||||
|
|
||||||
|
|
||||||
ACTIONS: dict = {}
|
ACTIONS: dict = {}
|
||||||
|
|||||||
+40
-63
@@ -20,12 +20,11 @@ block of authored text."""
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from functools import lru_cache
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .. import theme_tokens
|
||||||
from ..image_pipeline import _quantize, draw_text, hex_to_rgb, logical_render_size
|
from ..image_pipeline import _quantize, draw_text, hex_to_rgb, logical_render_size
|
||||||
from ..models import Frame, TextWidgetConfig, Widget
|
from ..models import Frame, TextWidgetConfig, Widget
|
||||||
from ..text_content import has_text
|
from ..text_content import has_text
|
||||||
@@ -40,66 +39,20 @@ LINE_HEIGHT_FACTOR = 1.35
|
|||||||
DEFAULT_FG = (0, 0, 0)
|
DEFAULT_FG = (0, 0, 0)
|
||||||
DEFAULT_BG = (255, 255, 255)
|
DEFAULT_BG = (255, 255, 255)
|
||||||
|
|
||||||
_FONT_DIR = Path(__file__).resolve().parent.parent / "fonts"
|
# The font-family table (a small curated set, not an open-ended picker --
|
||||||
|
# each entry needs a real vendored Regular/Bold/Italic/BoldItalic file)
|
||||||
# A small curated set, not an open-ended picker -- each entry needs a
|
# lives in app/theme_tokens.py now, shared with every modern-style
|
||||||
# real vendored Regular/Bold/Italic/BoldItalic file, so families that
|
# widget's own font resolution -- re-exported here under their original
|
||||||
# only ship as a variable font (Playfair Display, Lora, Merriweather,
|
# names since this was the text widget's own table before the theme
|
||||||
# stock "Inter"/"Source Sans 3" from Google Fonts) were skipped in favor
|
# system needed it too (see app/fonts/OFL-*.txt for each non-Noto
|
||||||
# of static builds from their own upstream repos where one exists (see
|
# family's own license/copyright).
|
||||||
# app/fonts/OFL-*.txt for each non-Noto family's own license/copyright --
|
DEFAULT_FONT_FAMILY = theme_tokens.DEFAULT_FONT_FAMILY
|
||||||
# they're all OFL, same as the Noto fonts already vendored here, but
|
FONT_FAMILIES = theme_tokens.FONT_FAMILIES
|
||||||
# each has a different copyright holder so gets its own license file
|
_FONT_FILES = theme_tokens._FONT_FILES
|
||||||
# rather than sharing app/fonts/OFL.txt).
|
_font = theme_tokens.font
|
||||||
DEFAULT_FONT_FAMILY = "sans"
|
|
||||||
FONT_FAMILIES: dict[str, str] = {
|
|
||||||
"sans": "Sans-serif (Noto Sans)",
|
|
||||||
"inter": "Inter",
|
|
||||||
"source_sans": "Source Sans",
|
|
||||||
"serif": "Serif (Noto Serif)",
|
|
||||||
"elegant": "Elegant serif (Crimson Text)",
|
|
||||||
"slab": "Slab serif (Arvo)",
|
|
||||||
"mono": "Monospace (IBM Plex Mono)",
|
|
||||||
}
|
|
||||||
_FONT_FILES = {
|
|
||||||
"sans": {
|
|
||||||
(False, False): "NotoSans-Regular.ttf", (True, False): "NotoSans-Bold.ttf",
|
|
||||||
(False, True): "NotoSans-Italic.ttf", (True, True): "NotoSans-BoldItalic.ttf",
|
|
||||||
},
|
|
||||||
"inter": {
|
|
||||||
(False, False): "Inter-Regular.ttf", (True, False): "Inter-Bold.ttf",
|
|
||||||
(False, True): "Inter-Italic.ttf", (True, True): "Inter-BoldItalic.ttf",
|
|
||||||
},
|
|
||||||
"source_sans": {
|
|
||||||
(False, False): "SourceSans3-Regular.ttf", (True, False): "SourceSans3-Bold.ttf",
|
|
||||||
(False, True): "SourceSans3-Italic.ttf", (True, True): "SourceSans3-BoldItalic.ttf",
|
|
||||||
},
|
|
||||||
"serif": {
|
|
||||||
(False, False): "NotoSerif-Regular.ttf", (True, False): "NotoSerif-Bold.ttf",
|
|
||||||
(False, True): "NotoSerif-Italic.ttf", (True, True): "NotoSerif-BoldItalic.ttf",
|
|
||||||
},
|
|
||||||
"elegant": {
|
|
||||||
(False, False): "CrimsonText-Regular.ttf", (True, False): "CrimsonText-Bold.ttf",
|
|
||||||
(False, True): "CrimsonText-Italic.ttf", (True, True): "CrimsonText-BoldItalic.ttf",
|
|
||||||
},
|
|
||||||
"slab": {
|
|
||||||
(False, False): "Arvo-Regular.ttf", (True, False): "Arvo-Bold.ttf",
|
|
||||||
(False, True): "Arvo-Italic.ttf", (True, True): "Arvo-BoldItalic.ttf",
|
|
||||||
},
|
|
||||||
"mono": {
|
|
||||||
(False, False): "IBMPlexMono-Regular.ttf", (True, False): "IBMPlexMono-Bold.ttf",
|
|
||||||
(False, True): "IBMPlexMono-Italic.ttf", (True, True): "IBMPlexMono-BoldItalic.ttf",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
_WORD_OR_SPACE = re.compile(r"\S+|\s+")
|
_WORD_OR_SPACE = re.compile(r"\S+|\s+")
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=256)
|
|
||||||
def _font(family: str, bold: bool, italic: bool, size: int) -> ImageFont.FreeTypeFont:
|
|
||||||
files = _FONT_FILES.get(family) or _FONT_FILES[DEFAULT_FONT_FAMILY]
|
|
||||||
return ImageFont.truetype(str(_FONT_DIR / files[(bold, italic)]), size)
|
|
||||||
|
|
||||||
|
|
||||||
def _paragraph_word_groups(paragraph: list[dict]) -> list[list[dict]]:
|
def _paragraph_word_groups(paragraph: list[dict]) -> list[list[dict]]:
|
||||||
"""One paragraph's styled runs -> word groups: each group is a list
|
"""One paragraph's styled runs -> word groups: each group is a list
|
||||||
of same-word sub-tokens that must stay glued together on one line
|
of same-word sub-tokens that must stay glued together on one line
|
||||||
@@ -224,6 +177,29 @@ def _render_text(cfg: TextWidgetConfig, target_w: int, target_h: int) -> Image.I
|
|||||||
return img
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def _render_dispatch(cfg: TextWidgetConfig, target_w: int, target_h: int,
|
||||||
|
palette_rgb: list | None, theme_name: str | None = None) -> Image.Image:
|
||||||
|
"""classic vs modern (app/html_render.py) -- shared by render() and
|
||||||
|
render_preview_png() so both honor render_style identically (weather
|
||||||
|
once shipped with its preview endpoint bypassing render_style
|
||||||
|
entirely by calling the classic renderer directly -- this shared
|
||||||
|
dispatch point exists specifically so that bug can't happen here).
|
||||||
|
palette_rgb is unused by the classic path (it never quantizes itself
|
||||||
|
-- see module docstring), only threaded through for modern's own
|
||||||
|
ordered_dither. theme_name is threaded through uniformly (every
|
||||||
|
modern-style widget's dispatch takes one) but build_text ignores it
|
||||||
|
-- see its own docstring for why (the text widget's font is a
|
||||||
|
per-widget, user-authored choice, not theme-driven)."""
|
||||||
|
if cfg.render_style == "modern":
|
||||||
|
# Local import: html_render pulls in Playwright, a real headless-
|
||||||
|
# Chromium dependency -- every other widget type, and this one's
|
||||||
|
# own classic path, should never pay for it.
|
||||||
|
from .. import html_render
|
||||||
|
|
||||||
|
return html_render.build_text(cfg, target_w, target_h, palette_rgb, theme_name)
|
||||||
|
return _render_text(cfg, target_w, target_h)
|
||||||
|
|
||||||
|
|
||||||
def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: int,
|
def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: int,
|
||||||
is_normal_wake: bool = True) -> Image.Image:
|
is_normal_wake: bool = True) -> Image.Image:
|
||||||
"""is_normal_wake is unused -- see app/widgets/whiteboard.py's
|
"""is_normal_wake is unused -- see app/widgets/whiteboard.py's
|
||||||
@@ -232,10 +208,11 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i
|
|||||||
cfg = db.get(TextWidgetConfig, widget.id)
|
cfg = db.get(TextWidgetConfig, widget.id)
|
||||||
if cfg is None or not has_text(cfg.content):
|
if cfg is None or not has_text(cfg.content):
|
||||||
return placeholder_image(target_w, target_h, ["Text widget", "not configured yet"])
|
return placeholder_image(target_w, target_h, ["Text widget", "not configured yet"])
|
||||||
return _render_text(cfg, target_w, target_h)
|
return _render_dispatch(cfg, target_w, target_h, frame.palette_rgb, frame.theme)
|
||||||
|
|
||||||
|
|
||||||
def render_preview_png(cfg: TextWidgetConfig, orientation: str, palette_rgb: list | None) -> bytes:
|
def render_preview_png(cfg: TextWidgetConfig, orientation: str, palette_rgb: list | None,
|
||||||
|
theme_name: str | None = None) -> bytes:
|
||||||
"""A normal browser-viewable PNG at full logical panel size --
|
"""A normal browser-viewable PNG at full logical panel size --
|
||||||
mirrors calendar_render.render_tasks_preview_png's relationship to
|
mirrors calendar_render.render_tasks_preview_png's relationship to
|
||||||
render_tasks (the dialog's own preview endpoint always renders at
|
render_tasks (the dialog's own preview endpoint always renders at
|
||||||
@@ -244,7 +221,7 @@ def render_preview_png(cfg: TextWidgetConfig, orientation: str, palette_rgb: lis
|
|||||||
import io
|
import io
|
||||||
|
|
||||||
target_w, target_h = logical_render_size(orientation)
|
target_w, target_h = logical_render_size(orientation)
|
||||||
img = _render_text(cfg, target_w, target_h)
|
img = _render_dispatch(cfg, target_w, target_h, palette_rgb, theme_name)
|
||||||
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
|
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
|
||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
quantized.convert("RGB").save(buf, format="PNG")
|
quantized.convert("RGB").save(buf, format="PNG")
|
||||||
|
|||||||
@@ -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 "", theme_name=frame.theme)
|
||||||
|
|
||||||
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,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from PIL import Image
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..image_pipeline import compose_into
|
from ..image_pipeline import compose_into
|
||||||
from ..models import Frame, Widget
|
from ..models import Frame, Widget, WhiteboardWidgetConfig
|
||||||
from ..routers.common import get_or_refresh_whiteboard_for_widget
|
from ..routers.common import get_or_refresh_whiteboard_for_widget
|
||||||
from ._shared import placeholder_image
|
from ._shared import placeholder_image
|
||||||
|
|
||||||
@@ -35,7 +35,17 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i
|
|||||||
# letterbox, never cropped: unlike a photo, losing part of a
|
# letterbox, never cropped: unlike a photo, losing part of a
|
||||||
# whiteboard to a crop loses actual content, not just some background
|
# whiteboard to a crop loses actual content, not just some background
|
||||||
# (see the old _render_whiteboard_mode's identical reasoning).
|
# (see the old _render_whiteboard_mode's identical reasoning).
|
||||||
return compose_into(source, faces=None, target_w=target_w, target_h=target_h, display_mode="letterbox")
|
composed = compose_into(source, faces=None, target_w=target_w, target_h=target_h, display_mode="letterbox")
|
||||||
|
cfg = db.get(WhiteboardWidgetConfig, widget.id)
|
||||||
|
if cfg and cfg.render_style == "modern":
|
||||||
|
# Local import: html_render pulls in Playwright, a real headless-
|
||||||
|
# Chromium dependency -- every other widget type, and this one's
|
||||||
|
# own classic path, should never pay for it.
|
||||||
|
from .. import html_render
|
||||||
|
|
||||||
|
return html_render.build_framed_image(composed, target_w, target_h, frame.palette_rgb, frame.theme,
|
||||||
|
"whiteboard")
|
||||||
|
return composed
|
||||||
|
|
||||||
|
|
||||||
def _check_now(db: Session, frame: Frame, widget: Widget) -> None:
|
def _check_now(db: Session, frame: Frame, widget: Widget) -> None:
|
||||||
|
|||||||
@@ -11,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
|
||||||
|
|||||||
@@ -5,6 +5,18 @@
|
|||||||
# Then execs uvicorn as the foreground/PID 1 process so it receives
|
# Then execs uvicorn as the foreground/PID 1 process so it receives
|
||||||
# Docker's stop signal directly.
|
# Docker's stop signal directly.
|
||||||
#
|
#
|
||||||
|
# Before either: fetch headless Chromium (app/html_render.py, the
|
||||||
|
# weather widget's opt-in "modern" render style) into
|
||||||
|
# PLAYWRIGHT_BROWSERS_PATH (set in the Dockerfile to a path on the /data
|
||||||
|
# volume) if it isn't already cached there -- see the Dockerfile's own
|
||||||
|
# comment for why this happens at startup instead of build time. Only
|
||||||
|
# the very first boot on a fresh volume actually downloads anything;
|
||||||
|
# every boot after that is a no-op ls check.
|
||||||
|
if [ -z "$(ls -A "$PLAYWRIGHT_BROWSERS_PATH" 2>/dev/null)" ]; then
|
||||||
|
echo "Fetching headless Chromium into $PLAYWRIGHT_BROWSERS_PATH (first boot on this volume)..." >&2
|
||||||
|
playwright install chromium-headless-shell
|
||||||
|
fi
|
||||||
|
#
|
||||||
# Wrapped in a restart loop, not a bare `node ... &`: a bare background
|
# Wrapped in a restart loop, not a bare `node ... &`: a bare background
|
||||||
# process that crashes stays dead for good, with nothing to bring it
|
# process that crashes stays dead for good, with nothing to bring it
|
||||||
# back -- turning any single render crash (a not-yet-found jsdom/
|
# back -- turning any single render crash (a not-yet-found jsdom/
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ from pathlib import Path
|
|||||||
|
|
||||||
_tmp_dir = tempfile.mkdtemp(prefix="espresso_frame_tests_")
|
_tmp_dir = tempfile.mkdtemp(prefix="espresso_frame_tests_")
|
||||||
os.environ["DATABASE_URL"] = f"sqlite:///{Path(_tmp_dir) / 'test.db'}"
|
os.environ["DATABASE_URL"] = f"sqlite:///{Path(_tmp_dir) / 'test.db'}"
|
||||||
|
# Same reasoning as DATABASE_URL above: logging_setup.configure_logging()
|
||||||
|
# also runs as an app.main import-time side effect and would otherwise
|
||||||
|
# try to create the real /data directory.
|
||||||
|
os.environ["LOG_PATH"] = str(Path(_tmp_dir) / "server.log")
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""Permission boundary + basic content checks for the admin log viewer
|
||||||
|
(routers/pages.py's admin_logs_page/admin_logs_download) -- see
|
||||||
|
CLAUDE.md's note that anything gated by an admin/permission check needs
|
||||||
|
a same-shape test: admin, non-admin logged in, logged out."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from app.logging_setup import LOG_PATH
|
||||||
|
from app.models import Frame
|
||||||
|
|
||||||
|
from .conftest import login, make_user
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_admin_and_user(client, db_session) -> None:
|
||||||
|
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||||
|
make_user(db_session, "bob")
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_can_view_logs(client, db_session):
|
||||||
|
_setup_admin_and_user(client, db_session)
|
||||||
|
login(client, "alice", "hunter22")
|
||||||
|
logging.getLogger("app.test").info("marker-line-for-test")
|
||||||
|
resp = client.get("/admin/logs")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "marker-line-for-test" in resp.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_admin_forbidden_from_logs(client, db_session):
|
||||||
|
_setup_admin_and_user(client, db_session)
|
||||||
|
login(client, "bob")
|
||||||
|
resp = client.get("/admin/logs")
|
||||||
|
assert resp.status_code == 403
|
||||||
|
resp = client.get("/admin/logs/download")
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_logged_out_redirected_from_logs_page(client, db_session):
|
||||||
|
_setup_admin_and_user(client, db_session)
|
||||||
|
client.cookies.clear() # /setup itself logs alice in
|
||||||
|
resp = client.get("/admin/logs")
|
||||||
|
assert resp.status_code == 303
|
||||||
|
assert resp.headers["location"] == "/login"
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_can_download_log_file(client, db_session):
|
||||||
|
_setup_admin_and_user(client, db_session)
|
||||||
|
login(client, "alice", "hunter22")
|
||||||
|
logging.getLogger("app.test").info("marker-line-for-download")
|
||||||
|
resp = client.get("/admin/logs/download")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert b"marker-line-for-download" in resp.content
|
||||||
|
|
||||||
|
|
||||||
|
def test_device_requests_are_logged(client, db_session):
|
||||||
|
_setup_admin_and_user(client, db_session)
|
||||||
|
frame = db_session.get(Frame, 1)
|
||||||
|
frame.device_id = "aabbccddeeff"
|
||||||
|
db_session.commit()
|
||||||
|
resp = client.get(f"/frame/config?id={frame.device_id}&token={frame.device_token}")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
login(client, "alice", "hunter22")
|
||||||
|
log_resp = client.get("/admin/logs")
|
||||||
|
# Jinja HTML-escapes the rendered <pre>, so "->" becomes "->".
|
||||||
|
assert f"GET /frame/config id={frame.device_id} -> 200" in log_resp.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_404s_before_any_log_written(client, db_session, monkeypatch):
|
||||||
|
_setup_admin_and_user(client, db_session)
|
||||||
|
login(client, "alice", "hunter22")
|
||||||
|
monkeypatch.setattr("app.routers.pages.LOG_PATH", LOG_PATH.parent / "does-not-exist.log")
|
||||||
|
resp = client.get("/admin/logs/download")
|
||||||
|
assert resp.status_code == 404
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
"""_reject_outlier_drops -- the outlier-rejection pass in the battery
|
"""_reject_outlier_drops and _smooth_percents -- the two outlier-rejection
|
||||||
remaining-time estimate (see routers/common.py's battery_estimate_s).
|
passes in the battery remaining-time estimate (see routers/common.py's
|
||||||
Pure function, no DB/HTTP -- (recency_weight, drop_pct) pairs in,
|
battery_estimate_s). Pure functions, no DB/HTTP."""
|
||||||
filtered pairs out."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from app.routers.common import _reject_outlier_drops
|
from app.routers.common import _reject_outlier_drops, _smooth_percents
|
||||||
|
|
||||||
|
|
||||||
def _steps(drops: list[float]) -> list[tuple[int, float]]:
|
def _steps(drops: list[float]) -> list[tuple[int, float]]:
|
||||||
@@ -69,3 +68,37 @@ def test_never_filters_down_to_nothing():
|
|||||||
steps = _steps([1, 1, 1, 1, 10, 10, 10, 10])
|
steps = _steps([1, 1, 1, 1, 10, 10, 10, 10])
|
||||||
kept = _reject_outlier_drops(steps)
|
kept = _reject_outlier_drops(steps)
|
||||||
assert len(kept) > 0
|
assert len(kept) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_smooth_corrects_isolated_spike():
|
||||||
|
percents = [70, 70, 70, 70, 70, 90, 70, 70, 70, 70, 70]
|
||||||
|
smoothed = _smooth_percents(percents)
|
||||||
|
assert smoothed[5] == 70
|
||||||
|
assert smoothed[:5] == percents[:5]
|
||||||
|
assert smoothed[6:] == percents[6:]
|
||||||
|
|
||||||
|
|
||||||
|
def test_smooth_corrects_short_burst():
|
||||||
|
"""The shape seen in production: several consecutive corrupted
|
||||||
|
reports (a 1M-ohm divider glitching for a few reports in a row, not
|
||||||
|
just one) spliced into an otherwise flat run. A step computed
|
||||||
|
between two of these looks like an ordinary small change, which is
|
||||||
|
exactly why _reject_outlier_drops alone can't catch this shape."""
|
||||||
|
percents = [53, 53, 53, 53, 41, 40, 40, 42, 53, 53, 53, 53]
|
||||||
|
smoothed = _smooth_percents(percents)
|
||||||
|
assert smoothed[4:8] == [53, 53, 53, 53]
|
||||||
|
assert smoothed[:4] == percents[:4]
|
||||||
|
assert smoothed[8:] == percents[8:]
|
||||||
|
|
||||||
|
|
||||||
|
def test_smooth_leaves_gradual_legitimate_trend_alone():
|
||||||
|
"""A slow, steady climb (recharge) or decline spread over many
|
||||||
|
reports is a real trend, not a local glitch -- each reading is close
|
||||||
|
to its own neighborhood's median, so nothing should be flagged."""
|
||||||
|
percents = list(range(80, 60, -2)) # 80, 78, 76, ... steady discharge
|
||||||
|
assert _smooth_percents(percents) == percents
|
||||||
|
|
||||||
|
|
||||||
|
def test_smooth_identical_readings_untouched():
|
||||||
|
percents = [50] * 12
|
||||||
|
assert _smooth_percents(percents) == percents
|
||||||
|
|||||||
@@ -144,3 +144,50 @@ def test_two_widget_frame_composites_both_and_next_targets_calendar(client, db_s
|
|||||||
photo_cfg = db_session.get(PhotoWidgetConfig, photo_widget.id)
|
photo_cfg = db_session.get(PhotoWidgetConfig, photo_widget.id)
|
||||||
assert cal_cfg.browse_offset == 1
|
assert cal_cfg.browse_offset == 1
|
||||||
assert photo_cfg.current_asset_id == "" # untouched -- NEXT was never bound to it
|
assert photo_cfg.current_asset_id == "" # untouched -- NEXT was never bound to it
|
||||||
|
|
||||||
|
|
||||||
|
def test_widgets_render_concurrently(client, db_session, monkeypatch):
|
||||||
|
"""Two independent, slow widgets on one frame should render in
|
||||||
|
roughly the time of the slowest one, not the sum -- the actual fix
|
||||||
|
for the "hold to cycle layouts times out and shows a false server-
|
||||||
|
failed status screen" bug: several network-backed widgets (photos,
|
||||||
|
weather, calendar) rendering one after another could push a single
|
||||||
|
/frame/* response past the firmware's fixed HTTP timeout even though
|
||||||
|
the server was simply still working."""
|
||||||
|
monkeypatch.setattr(widgets.photos, "immich_client_for", lambda frame: object())
|
||||||
|
monkeypatch.setattr(widgets.photos, "list_assets", lambda client, album_id: _ASSETS)
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
source = Image.new("RGB", (100, 80), (10, 20, 30))
|
||||||
|
|
||||||
|
def _slow_fetch(client, mode, asset_id):
|
||||||
|
time.sleep(0.25)
|
||||||
|
return source, None
|
||||||
|
|
||||||
|
monkeypatch.setattr(widgets.photos, "fetch_source_and_faces", _slow_fetch)
|
||||||
|
|
||||||
|
frame = Frame(
|
||||||
|
name="Concurrency Frame", device_id="112233445566", device_token="devtok-3",
|
||||||
|
manage_token="mtok-3", orientation="landscape", created_at=time.time(),
|
||||||
|
)
|
||||||
|
db_session.add(frame)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
widget_a = Widget(frame_id=frame.id, widget_type="photos", x=0, y=0, w=4, h=5,
|
||||||
|
sort_order=0, created_at=time.time())
|
||||||
|
widget_b = Widget(frame_id=frame.id, widget_type="photos", x=4, y=0, w=4, h=5,
|
||||||
|
sort_order=1, created_at=time.time())
|
||||||
|
db_session.add_all([widget_a, widget_b])
|
||||||
|
db_session.flush()
|
||||||
|
db_session.add(PhotoWidgetConfig(widget_id=widget_a.id, album_id="album-a"))
|
||||||
|
db_session.add(PhotoWidgetConfig(widget_id=widget_b.id, album_id="album-b"))
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
start = time.monotonic()
|
||||||
|
resp = client.get(f"/frame/image?id={frame.device_id}&token={frame.device_token}")
|
||||||
|
elapsed = time.monotonic() - start
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert len(resp.content) == EXPECTED_BYTES
|
||||||
|
# Serial would be ~0.5s (2 x 0.25s); concurrent should land near 0.25s.
|
||||||
|
assert elapsed < 0.45, f"widgets rendered serially, not concurrently ({elapsed:.2f}s)"
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""POST /api/frames/{id}/firmware/check -- the "Check now" button's
|
||||||
|
endpoint. update_available compares the latest Gitea release against
|
||||||
|
what's *staged* (firmware_available_version), not what the device is
|
||||||
|
actually running (device_firmware_version) -- those can differ once a
|
||||||
|
release has been staged/auto-applied but the frame hasn't woken up and
|
||||||
|
picked it up yet. running_version lets the UI tell "up to date" apart
|
||||||
|
from "staged, waiting for the frame to apply it" instead of collapsing
|
||||||
|
both into the same message."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app import gitea_releases
|
||||||
|
from app.models import Frame
|
||||||
|
|
||||||
|
from .conftest import csrf_headers
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_frame(db_session, monkeypatch, latest_version, **overrides):
|
||||||
|
"""firmware_update_checked_at starts at 0, so the endpoint always
|
||||||
|
tries a real Gitea fetch on a fresh frame regardless of force= --
|
||||||
|
stub it out rather than hitting the network."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
gitea_releases, "fetch_latest_release", lambda *a, **k: {"version": latest_version, "assets": {}}
|
||||||
|
)
|
||||||
|
frame = db_session.get(Frame, 1)
|
||||||
|
frame.firmware_update_repo_url = "https://git.example.com/owner/repo"
|
||||||
|
frame.device_board_variant = "devkit"
|
||||||
|
for key, value in overrides.items():
|
||||||
|
setattr(frame, key, value)
|
||||||
|
db_session.commit()
|
||||||
|
return frame
|
||||||
|
|
||||||
|
|
||||||
|
def test_up_to_date_when_running_matches_latest(client, db_session, monkeypatch):
|
||||||
|
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||||
|
_setup_frame(
|
||||||
|
db_session,
|
||||||
|
monkeypatch,
|
||||||
|
"1.4.1",
|
||||||
|
firmware_available_version="1.4.1",
|
||||||
|
device_firmware_version="1.4.1",
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.post("/api/frames/1/firmware/check", headers=csrf_headers(client))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["update_available"] is False
|
||||||
|
assert data["latest_version"] == "1.4.1"
|
||||||
|
assert data["running_version"] == "1.4.1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_staged_but_not_yet_running_is_not_update_available(client, db_session, monkeypatch):
|
||||||
|
"""A release already staged (e.g. by a previous auto-update) but not
|
||||||
|
yet applied by the device isn't "an update is available" -- there's
|
||||||
|
nothing left to fetch/stage -- but it also isn't silently "up to
|
||||||
|
date" from the UI's perspective, since running_version still lags."""
|
||||||
|
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||||
|
_setup_frame(
|
||||||
|
db_session,
|
||||||
|
monkeypatch,
|
||||||
|
"1.4.1",
|
||||||
|
firmware_available_version="1.4.1",
|
||||||
|
device_firmware_version="1.3.0",
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.post("/api/frames/1/firmware/check", headers=csrf_headers(client))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["update_available"] is False
|
||||||
|
assert data["latest_version"] == "1.4.1"
|
||||||
|
assert data["staged_version"] == "1.4.1"
|
||||||
|
assert data["running_version"] == "1.3.0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_available_reports_running_version(client, db_session, monkeypatch):
|
||||||
|
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||||
|
_setup_frame(
|
||||||
|
db_session,
|
||||||
|
monkeypatch,
|
||||||
|
"1.4.1",
|
||||||
|
firmware_available_version="1.3.0",
|
||||||
|
device_firmware_version="1.3.0",
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.post("/api/frames/1/firmware/check", headers=csrf_headers(client))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["update_available"] is True
|
||||||
|
assert data["running_version"] == "1.3.0"
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""app.html_render's shared ordered-dithering primitives -- ordered_dither
|
||||||
|
and ordered_dither_regions -- exercised directly against synthetic
|
||||||
|
images, no Chromium/Playwright involved (these two functions run purely
|
||||||
|
on whatever Image render_html_to_image already handed back)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from app import html_render
|
||||||
|
from app.image_pipeline import DEFAULT_PALETTE_RGB
|
||||||
|
|
||||||
|
_PALETTE = set(DEFAULT_PALETTE_RGB)
|
||||||
|
# A muddy hue nowhere near any of the 6 exact palette colors -- at the
|
||||||
|
# "modern" style's tuned default amplitude (48), this should still snap
|
||||||
|
# flatly to a single nearest ink (see theme_tokens.py's module docstring
|
||||||
|
# for why 48 was chosen for icon/text legibility); only a much higher
|
||||||
|
# amplitude (as a rich theme's accent_amplitude would use) stipples it
|
||||||
|
# into a multi-ink approximation.
|
||||||
|
_RICH_HUE = (168, 75, 42) # a terracotta-ish RGB, not one of the 6 inks
|
||||||
|
|
||||||
|
|
||||||
|
def test_ordered_dither_output_is_exact_palette_colors():
|
||||||
|
img = Image.new("RGB", (40, 30), (128, 128, 128))
|
||||||
|
dithered = html_render.ordered_dither(img, None)
|
||||||
|
assert set(dithered.getdata()) <= _PALETTE
|
||||||
|
|
||||||
|
|
||||||
|
def test_ordered_dither_regions_outside_the_region_matches_plain_dither():
|
||||||
|
"""Pixels outside every accent_regions rect must come out identical
|
||||||
|
to a plain ordered_dither call at base_amplitude -- the region-aware
|
||||||
|
variant must not perturb anything it wasn't asked to."""
|
||||||
|
img = Image.new("RGB", (100, 80), (90, 140, 200))
|
||||||
|
base_only = html_render.ordered_dither(img, None, amplitude=48.0)
|
||||||
|
regions = html_render.ordered_dither_regions(
|
||||||
|
img, None, base_amplitude=48.0, accent_regions=[((10, 10, 40, 30), 130.0)]
|
||||||
|
)
|
||||||
|
for x in range(100):
|
||||||
|
for y in range(80):
|
||||||
|
if 10 <= x < 40 and 10 <= y < 30:
|
||||||
|
continue # inside the accent region -- expected to differ
|
||||||
|
assert regions.getpixel((x, y)) == base_only.getpixel((x, y))
|
||||||
|
|
||||||
|
|
||||||
|
def test_ordered_dither_regions_stipples_a_rich_hue_the_base_amplitude_would_flatten():
|
||||||
|
"""The whole reason ordered_dither_regions exists: a rich accent hue
|
||||||
|
dithered at the base (icon/text-safe) amplitude just snaps to one
|
||||||
|
nearest ink, but the same hue in an accent region at a theme's higher
|
||||||
|
accent_amplitude resolves to a believable multi-ink stipple instead --
|
||||||
|
assert that difference directly, not just "some image came back"."""
|
||||||
|
img = Image.new("RGB", (60, 60), _RICH_HUE)
|
||||||
|
rect = (0, 0, 60, 60)
|
||||||
|
|
||||||
|
flat = html_render.ordered_dither(img, None, amplitude=48.0)
|
||||||
|
richer = html_render.ordered_dither_regions(img, None, base_amplitude=48.0, accent_regions=[(rect, 130.0)])
|
||||||
|
|
||||||
|
assert len(set(flat.getdata())) == 1
|
||||||
|
assert len(set(richer.getdata())) > 1
|
||||||
|
assert set(richer.getdata()) <= _PALETTE
|
||||||
|
|
||||||
|
|
||||||
|
def test_ordered_dither_regions_with_no_accent_regions_matches_plain_dither():
|
||||||
|
img = Image.new("RGB", (30, 30), (50, 60, 70))
|
||||||
|
assert list(html_render.ordered_dither_regions(img, None, base_amplitude=48.0).getdata()) == \
|
||||||
|
list(html_render.ordered_dither(img, None, amplitude=48.0).getdata())
|
||||||
@@ -92,6 +92,18 @@ def test_expected_columns_exist_on_current_schema():
|
|||||||
button_action_indexes = {idx["name"] for idx in inspector.get_indexes("frame_button_actions")}
|
button_action_indexes = {idx["name"] for idx in inspector.get_indexes("frame_button_actions")}
|
||||||
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 "render_style" in weather_widget_columns # migration 31
|
||||||
|
assert {"photo_palette_rgb", "photo_dither_strength"} <= frame_columns # migration 32
|
||||||
|
assert "render_style" in battery_widget_columns # migration 33
|
||||||
|
assert "render_style" in text_widget_columns # migration 34
|
||||||
|
assert "render_style" in task_widget_columns # migration 35
|
||||||
|
static_widget_columns = {c["name"] for c in inspector.get_columns("static_widget_configs")}
|
||||||
|
assert "render_style" in static_widget_columns # migration 36
|
||||||
|
whiteboard_widget_columns = {c["name"] for c in inspector.get_columns("whiteboard_widget_configs")}
|
||||||
|
assert "render_style" in whiteboard_widget_columns # migration 37
|
||||||
|
calendar_widget_columns = {c["name"] for c in inspector.get_columns("calendar_widget_configs")}
|
||||||
|
assert "render_style" in calendar_widget_columns # migration 38
|
||||||
|
|
||||||
|
|
||||||
# --- widget system backfill (migration 16 + _ensure_widgets_backfilled) ---
|
# --- widget system backfill (migration 16 + _ensure_widgets_backfilled) ---
|
||||||
@@ -331,6 +343,25 @@ def test_migration_29_adds_hold_action_columns_to_an_existing_database(db_sessio
|
|||||||
assert frame.last_cycled_layout_id is None
|
assert frame.last_cycled_layout_id is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_30_adds_now_displaying_columns_to_an_existing_database(db_session):
|
||||||
|
"""Exercises _migration_30's real guarded ALTER path (frames isn't
|
||||||
|
dropped/recreated by the pre-widget-system replay tests, so its
|
||||||
|
columns must be added defensively, same reasoning as migration
|
||||||
|
26/27/29's own comments)."""
|
||||||
|
with db_module.engine.begin() as conn:
|
||||||
|
conn.execute(text("UPDATE schema_version SET version = 29"))
|
||||||
|
|
||||||
|
run_migrations()
|
||||||
|
|
||||||
|
with db_module.engine.connect() as conn:
|
||||||
|
version = conn.execute(text("SELECT version FROM schema_version")).scalar()
|
||||||
|
assert version == MIGRATIONS[-1][0]
|
||||||
|
|
||||||
|
frame = db_session.get(Frame, 1)
|
||||||
|
assert frame.last_displayed_image is None
|
||||||
|
assert frame.last_displayed_at == 0.0
|
||||||
|
|
||||||
|
|
||||||
def test_migration_17_and_18_extract_tasks_into_a_standalone_multi_list_widget(db_session):
|
def test_migration_17_and_18_extract_tasks_into_a_standalone_multi_list_widget(db_session):
|
||||||
"""Exercises _migration_17 and _migration_18's actual data-extraction
|
"""Exercises _migration_17 and _migration_18's actual data-extraction
|
||||||
SQL back to back (the real "existing widget-system database
|
SQL back to back (the real "existing widget-system database
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""GET /api/frames/{id}/now-displaying -- the frozen half of the header
|
||||||
|
preview pair (see routers/device.py's _record_last_displayed). Distinct
|
||||||
|
from /preview (test_frame_preview.py): that one always live-renders,
|
||||||
|
this one serves back exactly whatever bytes a device-facing endpoint
|
||||||
|
last actually sent, recorded as a side effect of /frame/image,
|
||||||
|
/frame/advance, and /frame/back."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from app.image_pipeline import logical_render_size
|
||||||
|
from app.models import Frame
|
||||||
|
|
||||||
|
from .conftest import link_user, login, make_user
|
||||||
|
|
||||||
|
|
||||||
|
def test_now_displaying_404s_before_any_device_fetch(client, db_session):
|
||||||
|
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||||
|
|
||||||
|
resp = client.get("/api/frames/1/now-displaying")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_frame_image_records_now_displaying(client, db_session):
|
||||||
|
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||||
|
frame = db_session.get(Frame, 1)
|
||||||
|
|
||||||
|
resp = client.get("/frame/image")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
resp = client.get("/api/frames/1/now-displaying")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.headers["content-type"] == "image/png"
|
||||||
|
assert "X-Displayed-At" in resp.headers
|
||||||
|
assert float(resp.headers["X-Displayed-At"]) > 0
|
||||||
|
|
||||||
|
img = Image.open(io.BytesIO(resp.content))
|
||||||
|
assert img.size == logical_render_size(frame.orientation)
|
||||||
|
|
||||||
|
|
||||||
|
def test_advance_and_back_also_update_now_displaying(client, db_session):
|
||||||
|
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||||
|
db_session.get(Frame, 1)
|
||||||
|
|
||||||
|
client.get("/frame/image")
|
||||||
|
first = client.get("/api/frames/1/now-displaying")
|
||||||
|
assert first.status_code == 200
|
||||||
|
|
||||||
|
resp = client.post("/frame/advance")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
after_advance = client.get("/api/frames/1/now-displaying")
|
||||||
|
assert after_advance.status_code == 200
|
||||||
|
|
||||||
|
resp = client.post("/frame/back")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
after_back = client.get("/api/frames/1/now-displaying")
|
||||||
|
assert after_back.status_code == 200
|
||||||
|
assert float(after_back.headers["X-Displayed-At"]) >= float(first.headers["X-Displayed-At"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_now_displaying_visible_to_linked_user(client, db_session):
|
||||||
|
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||||
|
bob = make_user(db_session, "bob")
|
||||||
|
frame = db_session.get(Frame, 1)
|
||||||
|
link_user(db_session, bob, frame)
|
||||||
|
client.get("/frame/image")
|
||||||
|
|
||||||
|
client.cookies.clear()
|
||||||
|
login(client, "bob")
|
||||||
|
resp = client.get("/api/frames/1/now-displaying")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_now_displaying_hidden_from_unrelated_user(client, db_session):
|
||||||
|
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||||
|
make_user(db_session, "mallory")
|
||||||
|
client.get("/frame/image")
|
||||||
|
|
||||||
|
client.cookies.clear()
|
||||||
|
login(client, "mallory")
|
||||||
|
resp = client.get("/api/frames/1/now-displaying")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_now_displaying_requires_login(client, db_session):
|
||||||
|
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||||
|
client.get("/frame/image")
|
||||||
|
|
||||||
|
client.cookies.clear()
|
||||||
|
resp = client.get("/api/frames/1/now-displaying")
|
||||||
|
assert resp.status_code in (401, 403)
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""app.theme_tokens -- resolve_theme()'s fallback logic (does "classic"
|
||||||
|
stay a byte-identical no-op vs. each widget kind's pre-theme-system
|
||||||
|
look?) and the font table every preset resolves against. No rendering
|
||||||
|
here -- see test_html_render.py for ordered_dither_regions and each
|
||||||
|
widget's own test_widgets_*.py for dispatch-level "does a theme actually
|
||||||
|
change the output" coverage."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app import panel_style, theme_tokens
|
||||||
|
|
||||||
|
|
||||||
|
def test_classic_weather_matches_historical_fixed_gradient():
|
||||||
|
"""Weather's modern style never went through panel_style.THEME --
|
||||||
|
its header was always this fixed blue gradient (see html_render.py's
|
||||||
|
removed ACCENT_START/ACCENT_END). "classic" must reproduce it
|
||||||
|
byte-for-byte so themes are additive, not a silent regression."""
|
||||||
|
resolved = theme_tokens.resolve_theme("classic", "weather", None)
|
||||||
|
assert resolved["accent_hex"] == "#1c4fd6"
|
||||||
|
assert resolved["accent_hex_dark"] == "#6fa8ff"
|
||||||
|
|
||||||
|
|
||||||
|
def test_classic_tasks_and_calendar_use_flat_theme_ink():
|
||||||
|
"""Tasks/calendar's classic accent was always a flat single ink (no
|
||||||
|
gradient) resolved through panel_style.THEME -- both ends of the
|
||||||
|
"gradient" must be that same ink, not two different shades."""
|
||||||
|
tasks = theme_tokens.resolve_theme("classic", "tasks", None)
|
||||||
|
calendar = theme_tokens.resolve_theme("classic", "calendar", None)
|
||||||
|
assert tasks["accent_hex"] == tasks["accent_hex_dark"]
|
||||||
|
assert calendar["accent_hex"] == calendar["accent_hex_dark"]
|
||||||
|
|
||||||
|
from app.image_pipeline import DEFAULT_PALETTE_RGB
|
||||||
|
|
||||||
|
expected_tasks = "#%02x%02x%02x" % tuple(DEFAULT_PALETTE_RGB[panel_style.THEME_TASKS])
|
||||||
|
expected_calendar = "#%02x%02x%02x" % tuple(DEFAULT_PALETTE_RGB[panel_style.THEME_CALENDAR])
|
||||||
|
assert tasks["accent_hex"] == expected_tasks
|
||||||
|
assert calendar["accent_hex"] == expected_calendar
|
||||||
|
|
||||||
|
|
||||||
|
def test_classic_unmapped_widget_kind_falls_back_to_black():
|
||||||
|
resolved = theme_tokens.resolve_theme("classic", "battery", None)
|
||||||
|
from app.image_pipeline import DEFAULT_PALETTE_RGB
|
||||||
|
|
||||||
|
assert resolved["accent_hex"] == "#%02x%02x%02x" % tuple(DEFAULT_PALETTE_RGB[panel_style.BLACK])
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_theme_name_falls_back_to_classic():
|
||||||
|
assert theme_tokens.resolve_theme("not-a-real-theme", "weather", None) == \
|
||||||
|
theme_tokens.resolve_theme("classic", "weather", None)
|
||||||
|
assert theme_tokens.resolve_theme(None, "weather", None) == \
|
||||||
|
theme_tokens.resolve_theme("classic", "weather", None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_preset_resolves_without_error_for_every_widget_kind():
|
||||||
|
widget_kinds = ["weather", "tasks", "calendar", "battery", "text", "static", "whiteboard"]
|
||||||
|
for theme_name in theme_tokens.THEMES:
|
||||||
|
for kind in widget_kinds:
|
||||||
|
resolved = theme_tokens.resolve_theme(theme_name, kind, None)
|
||||||
|
assert resolved["accent_hex"].startswith("#") and len(resolved["accent_hex"]) == 7
|
||||||
|
assert resolved["accent_hex_dark"].startswith("#") and len(resolved["accent_hex_dark"]) == 7
|
||||||
|
assert resolved["font_family"] in theme_tokens.FONT_FAMILIES
|
||||||
|
assert Path(resolved["font_regular"]).exists()
|
||||||
|
assert Path(resolved["font_bold"]).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_gradient_theme_has_equal_accent_stops():
|
||||||
|
""""moss" is configured with gradient=False -- its two CSS gradient
|
||||||
|
stops must be identical (a flat fill), unlike a gradient theme's."""
|
||||||
|
resolved = theme_tokens.resolve_theme("moss", "tasks", None)
|
||||||
|
assert resolved["gradient"] is False
|
||||||
|
assert resolved["accent_hex"] == resolved["accent_hex_dark"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_gradient_theme_has_a_darker_second_stop():
|
||||||
|
resolved = theme_tokens.resolve_theme("terracotta", "tasks", None)
|
||||||
|
assert resolved["gradient"] is True
|
||||||
|
assert resolved["accent_hex"] == "#a84b2a"
|
||||||
|
assert resolved["accent_hex_dark"] != resolved["accent_hex"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_font_path_covers_every_family_and_style_combination():
|
||||||
|
for family in theme_tokens.FONT_FAMILIES:
|
||||||
|
for bold in (False, True):
|
||||||
|
for italic in (False, True):
|
||||||
|
assert theme_tokens.font_path(family, bold, italic).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_font_path_falls_back_to_default_family_for_unknown_name():
|
||||||
|
assert theme_tokens.font_path("not-a-real-family", False, False) == \
|
||||||
|
theme_tokens.font_path(theme_tokens.DEFAULT_FONT_FAMILY, False, False)
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -7,17 +7,19 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from app import widgets
|
from PIL import Image
|
||||||
|
|
||||||
|
from app import html_render, widgets
|
||||||
from app.models import BatteryWidgetConfig, Frame, Widget
|
from app.models import BatteryWidgetConfig, Frame, Widget
|
||||||
|
|
||||||
|
|
||||||
def _make_widget(db_session, mode="detailed") -> tuple[Frame, Widget]:
|
def _make_widget(db_session, mode="detailed", render_style="classic") -> tuple[Frame, Widget]:
|
||||||
frame = db_session.get(Frame, 1)
|
frame = db_session.get(Frame, 1)
|
||||||
widget = Widget(frame_id=frame.id, widget_type="battery", x=0, y=0, w=1, h=1,
|
widget = Widget(frame_id=frame.id, widget_type="battery", x=0, y=0, w=1, h=1,
|
||||||
sort_order=0, created_at=time.time())
|
sort_order=0, created_at=time.time())
|
||||||
db_session.add(widget)
|
db_session.add(widget)
|
||||||
db_session.flush()
|
db_session.flush()
|
||||||
db_session.add(BatteryWidgetConfig(widget_id=widget.id, mode=mode))
|
db_session.add(BatteryWidgetConfig(widget_id=widget.id, mode=mode, render_style=render_style))
|
||||||
db_session.commit()
|
db_session.commit()
|
||||||
return frame, widget
|
return frame, widget
|
||||||
|
|
||||||
@@ -81,3 +83,19 @@ def test_no_button_actions():
|
|||||||
advance/back/check."""
|
advance/back/check."""
|
||||||
assert widgets.battery.ACTIONS == {}
|
assert widgets.battery.ACTIONS == {}
|
||||||
assert widgets.battery.ACTION_LABELS == {}
|
assert widgets.battery.ACTION_LABELS == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_modern_style(db_session, monkeypatch):
|
||||||
|
frame, widget = _make_widget(db_session, render_style="modern")
|
||||||
|
frame.battery_percent = 42
|
||||||
|
frame.battery_as_of = time.time()
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
def _stub(html, target_w, target_h):
|
||||||
|
return Image.new("RGB", (target_w, target_h), (255, 255, 255))
|
||||||
|
|
||||||
|
monkeypatch.setattr(html_render, "render_html_to_image", _stub)
|
||||||
|
|
||||||
|
img = widgets.battery.render(db_session, frame, widget, 300, 200)
|
||||||
|
assert img.size == (300, 200)
|
||||||
|
assert img.mode == "RGB"
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from app import widgets
|
from app import widgets
|
||||||
from app.db import widget_locked
|
from app.db import widget_locked
|
||||||
from app.models import CalendarWidgetConfig, Frame, Widget
|
from app.models import CalendarWidgetConfig, Frame, Widget
|
||||||
@@ -163,3 +165,78 @@ def test_month_view_falls_back_to_agenda_layout_below_small_tier(db_session, mon
|
|||||||
_stub_fetches(monkeypatch)
|
_stub_fetches(monkeypatch)
|
||||||
img = widgets.calendar.render(db_session, frame, widget, 300, 192)
|
img = widgets.calendar.render(db_session, frame, widget, 300, 192)
|
||||||
assert img.size == (300, 192)
|
assert img.size == (300, 192)
|
||||||
|
|
||||||
|
|
||||||
|
# --- "modern" style (app/calendar_html_render.py) -----------------------
|
||||||
|
# No real browser here -- html_render.render_html_to_image is monkeypatched
|
||||||
|
# to a stub, so these exercise calendar.py's dispatch + calendar_html_
|
||||||
|
# render's own layout/data logic, not Playwright/Chromium itself.
|
||||||
|
|
||||||
|
def _stub_render_html_to_image(monkeypatch):
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from app import html_render
|
||||||
|
|
||||||
|
monkeypatch.setattr(html_render, "render_html_to_image",
|
||||||
|
lambda html, target_w, target_h: Image.new("RGB", (target_w, target_h), (255, 255, 255)))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("view", ["agenda", "today_tomorrow", "week", "month"])
|
||||||
|
def test_render_modern_style_every_view(db_session, monkeypatch, view):
|
||||||
|
"""All four view modes have a modern-style builder (unlike weather's
|
||||||
|
own current/daily-only modern style) -- each must dispatch correctly
|
||||||
|
from calendar.py's render()."""
|
||||||
|
frame, widget = _make_widget(db_session, view=view, render_style="modern")
|
||||||
|
events = [{"summary": "Standup", "start": "2026-07-31T09:00:00+00:00",
|
||||||
|
"end": "2026-07-31T09:30:00+00:00", "all_day": False,
|
||||||
|
"sources": [{"owner_display_name": "Alice", "color_index": None}]}]
|
||||||
|
_stub_fetches(monkeypatch, events=events)
|
||||||
|
_stub_render_html_to_image(monkeypatch)
|
||||||
|
|
||||||
|
img = widgets.calendar.render(db_session, frame, widget, 380, 300)
|
||||||
|
assert img.size == (380, 300)
|
||||||
|
assert img.mode == "RGB"
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_modern_month_falls_back_to_agenda_below_small_tier(db_session, monkeypatch):
|
||||||
|
"""Same "month needs real column width" fallback classic has (see
|
||||||
|
test_month_view_falls_back_to_agenda_layout_below_small_tier above),
|
||||||
|
still honored when render_style is modern."""
|
||||||
|
frame, widget = _make_widget(db_session, view="month", render_style="modern")
|
||||||
|
_stub_fetches(monkeypatch)
|
||||||
|
_stub_render_html_to_image(monkeypatch)
|
||||||
|
|
||||||
|
img = widgets.calendar.render(db_session, frame, widget, 300, 192)
|
||||||
|
assert img.size == (300, 192)
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_modern_threads_frame_theme_through_to_resolve_theme(db_session, monkeypatch):
|
||||||
|
"""Same spy-on-resolve_theme approach as test_widgets_weather.py's
|
||||||
|
equivalent test -- confirms calendar.py's render() passes frame.theme
|
||||||
|
into calendar_html_render.build (proving the widget-level threading,
|
||||||
|
not re-testing ordered_dither_regions itself, which
|
||||||
|
test_html_render.py already covers)."""
|
||||||
|
from app import calendar_html_render, theme_tokens
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
real_resolve = theme_tokens.resolve_theme
|
||||||
|
|
||||||
|
def _spy(theme_name, widget_kind, palette_rgb):
|
||||||
|
calls.append((theme_name, widget_kind))
|
||||||
|
return real_resolve(theme_name, widget_kind, palette_rgb)
|
||||||
|
|
||||||
|
monkeypatch.setattr(theme_tokens, "resolve_theme", _spy)
|
||||||
|
monkeypatch.setattr(calendar_html_render, "theme_tokens", theme_tokens)
|
||||||
|
|
||||||
|
frame, widget = _make_widget(db_session, view="agenda", render_style="modern")
|
||||||
|
frame.theme = "moss"
|
||||||
|
# calendar.py's render() takes widget_locked's write path (it resets
|
||||||
|
# browse_offset on a normal wake), which commits and would otherwise
|
||||||
|
# expire-and-reload frame from the DB, discarding this uncommitted
|
||||||
|
# attribute change.
|
||||||
|
db_session.commit()
|
||||||
|
_stub_fetches(monkeypatch)
|
||||||
|
_stub_render_html_to_image(monkeypatch)
|
||||||
|
|
||||||
|
widgets.calendar.render(db_session, frame, widget, 380, 300)
|
||||||
|
assert ("moss", "calendar") in calls
|
||||||
|
|||||||
@@ -109,6 +109,35 @@ def test_advance_action_is_a_no_op_when_unconfigured(db_session):
|
|||||||
assert cfg.current_asset_id == ""
|
assert cfg.current_asset_id == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_quantizes_against_photo_palette_not_the_main_frame_palette(db_session, monkeypatch):
|
||||||
|
"""Regression/design test: photos.py's render() must quantize against
|
||||||
|
Frame.photo_palette_rgb, genuinely independent of Frame.palette_rgb --
|
||||||
|
the whole point of giving photos its own palette (see widgets/photos.
|
||||||
|
py's module docstring). Uses a custom photo_palette_rgb whose "black"
|
||||||
|
slot is a distinctive color that doesn't appear anywhere in
|
||||||
|
DEFAULT_PALETTE_RGB, so the assertion only passes if photo_palette_rgb
|
||||||
|
was actually the one used."""
|
||||||
|
frame, widget = _make_widget(db_session)
|
||||||
|
custom_photo_palette = [
|
||||||
|
[10, 20, 30], [255, 255, 255], [255, 219, 0], [207, 0, 15], [0, 39, 133], [0, 133, 55],
|
||||||
|
]
|
||||||
|
frame.photo_palette_rgb = custom_photo_palette
|
||||||
|
frame.photo_dither_strength = 0.0 # flat quantize -- exact, no diffusion noise to account for
|
||||||
|
db_session.commit()
|
||||||
|
assert frame.palette_rgb is None # main palette stays at its default throughout
|
||||||
|
|
||||||
|
monkeypatch.setattr(widgets.photos, "immich_client_for", lambda frame: object())
|
||||||
|
monkeypatch.setattr(widgets.photos, "list_assets", lambda client, album_id: _ASSETS)
|
||||||
|
# A near-black source photo -- under the MAIN default palette this
|
||||||
|
# would quantize to (0, 0, 0); under custom_photo_palette's distinctive
|
||||||
|
# "black" slot it must quantize to exactly (10, 20, 30) instead.
|
||||||
|
source = Image.new("RGB", (100, 80), (5, 5, 5))
|
||||||
|
monkeypatch.setattr(widgets.photos, "fetch_source_and_faces", lambda client, mode, asset_id: (source, None))
|
||||||
|
|
||||||
|
img = widgets.photos.render(db_session, frame, widget, 400, 300)
|
||||||
|
assert set(img.getdata()) == {(10, 20, 30)}
|
||||||
|
|
||||||
|
|
||||||
def test_render_does_not_advance_when_locked(db_session, monkeypatch):
|
def test_render_does_not_advance_when_locked(db_session, monkeypatch):
|
||||||
frame, widget = _make_widget(db_session)
|
frame, widget = _make_widget(db_session)
|
||||||
monkeypatch.setattr(widgets.photos, "immich_client_for", lambda frame: object())
|
monkeypatch.setattr(widgets.photos, "immich_client_for", lambda frame: object())
|
||||||
|
|||||||
@@ -72,3 +72,18 @@ def test_no_button_actions():
|
|||||||
"""A fixed uploaded image -- nothing to advance/back/check."""
|
"""A fixed uploaded image -- nothing to advance/back/check."""
|
||||||
assert widgets.static_image.ACTIONS == {}
|
assert widgets.static_image.ACTIONS == {}
|
||||||
assert widgets.static_image.ACTION_LABELS == {}
|
assert widgets.static_image.ACTION_LABELS == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_modern_style(db_session, monkeypatch):
|
||||||
|
from app import html_render
|
||||||
|
|
||||||
|
frame, widget = _make_widget(db_session, image=_png_bytes(), render_style="modern")
|
||||||
|
|
||||||
|
def _stub(html, target_w, target_h):
|
||||||
|
return Image.new("RGB", (target_w, target_h), (255, 255, 255))
|
||||||
|
|
||||||
|
monkeypatch.setattr(html_render, "render_html_to_image", _stub)
|
||||||
|
|
||||||
|
img = widgets.static_image.render(db_session, frame, widget, 300, 200)
|
||||||
|
assert img.size == (300, 200)
|
||||||
|
assert img.mode == "RGB"
|
||||||
|
|||||||
@@ -129,3 +129,57 @@ def test_no_button_actions():
|
|||||||
weather -- nothing to advance/back/force."""
|
weather -- nothing to advance/back/force."""
|
||||||
assert widgets.tasks.ACTIONS == {}
|
assert widgets.tasks.ACTIONS == {}
|
||||||
assert widgets.tasks.ACTION_LABELS == {}
|
assert widgets.tasks.ACTION_LABELS == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_modern_style(db_session, monkeypatch):
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from app import html_render
|
||||||
|
|
||||||
|
frame, widget = _make_widget(db_session, render_style="modern", name="Chores")
|
||||||
|
tasks = [
|
||||||
|
{"summary": "Buy milk", "due": None, "completed_at": None,
|
||||||
|
"owner_display_name": "Alice", "color_index": None},
|
||||||
|
{"summary": "Walk the dog", "due": "2026-08-01", "completed_at": None,
|
||||||
|
"owner_display_name": "Alice", "color_index": None},
|
||||||
|
]
|
||||||
|
monkeypatch.setattr(widgets.tasks, "get_or_refresh_tasks_for_widget", lambda db, frame, widget: tasks)
|
||||||
|
|
||||||
|
def _stub(html, target_w, target_h):
|
||||||
|
return Image.new("RGB", (target_w, target_h), (255, 255, 255))
|
||||||
|
|
||||||
|
monkeypatch.setattr(html_render, "render_html_to_image", _stub)
|
||||||
|
|
||||||
|
img = widgets.tasks.render(db_session, frame, widget, 300, 200)
|
||||||
|
assert img.size == (300, 200)
|
||||||
|
assert img.mode == "RGB"
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_modern_threads_frame_theme_through_to_resolve_theme(db_session, monkeypatch):
|
||||||
|
"""Same spy-on-resolve_theme approach as test_widgets_weather.py's
|
||||||
|
equivalent test -- confirms tasks.py's render() passes frame.theme
|
||||||
|
into html_render.build_tasks (proving the widget-level threading, not
|
||||||
|
re-testing ordered_dither_regions itself, which test_html_render.py
|
||||||
|
already covers)."""
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from app import html_render, theme_tokens
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
real_resolve = theme_tokens.resolve_theme
|
||||||
|
|
||||||
|
def _spy(theme_name, widget_kind, palette_rgb):
|
||||||
|
calls.append((theme_name, widget_kind))
|
||||||
|
return real_resolve(theme_name, widget_kind, palette_rgb)
|
||||||
|
|
||||||
|
monkeypatch.setattr(theme_tokens, "resolve_theme", _spy)
|
||||||
|
monkeypatch.setattr(html_render, "theme_tokens", theme_tokens)
|
||||||
|
monkeypatch.setattr(html_render, "render_html_to_image",
|
||||||
|
lambda html, target_w, target_h: Image.new("RGB", (target_w, target_h), (255, 255, 255)))
|
||||||
|
|
||||||
|
frame, widget = _make_widget(db_session, render_style="modern")
|
||||||
|
frame.theme = "slate"
|
||||||
|
monkeypatch.setattr(widgets.tasks, "get_or_refresh_tasks_for_widget", lambda db, frame, widget: [])
|
||||||
|
|
||||||
|
widgets.tasks.render(db_session, frame, widget, 300, 200)
|
||||||
|
assert ("slate", "tasks") in calls
|
||||||
|
|||||||
@@ -121,3 +121,24 @@ def test_no_button_actions():
|
|||||||
"""Fixed authored text -- nothing to advance/back/check."""
|
"""Fixed authored text -- nothing to advance/back/check."""
|
||||||
assert widgets.text.ACTIONS == {}
|
assert widgets.text.ACTIONS == {}
|
||||||
assert widgets.text.ACTION_LABELS == {}
|
assert widgets.text.ACTION_LABELS == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_modern_style(db_session, monkeypatch):
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from app import html_render
|
||||||
|
|
||||||
|
frame, widget = _make_widget(
|
||||||
|
db_session,
|
||||||
|
content=[[_run("Hello, ", bold=True), _run("world!", italic=True, color="#cc0000")]],
|
||||||
|
render_style="modern",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _stub(html, target_w, target_h):
|
||||||
|
return Image.new("RGB", (target_w, target_h), (255, 255, 255))
|
||||||
|
|
||||||
|
monkeypatch.setattr(html_render, "render_html_to_image", _stub)
|
||||||
|
|
||||||
|
img = widgets.text.render(db_session, frame, widget, 300, 200)
|
||||||
|
assert img.size == (300, 200)
|
||||||
|
assert img.mode == "RGB"
|
||||||
|
|||||||
@@ -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,110 @@ 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"
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_modern_daily_threads_frame_theme_through_to_resolve_theme(db_session, monkeypatch):
|
||||||
|
"""widgets/weather.py's render() must pass frame.theme all the way
|
||||||
|
into html_render.build_daily's theme resolution -- spies on
|
||||||
|
theme_tokens.resolve_theme (still delegating to the real
|
||||||
|
implementation) rather than diffing final pixels, since the stubbed
|
||||||
|
render_html_to_image below never actually executes the template's CSS
|
||||||
|
(that's the whole point of stubbing out Chromium), so a theme's
|
||||||
|
accent color has nothing to visibly change in the fake screenshot."""
|
||||||
|
from app import theme_tokens
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
real_resolve = theme_tokens.resolve_theme
|
||||||
|
|
||||||
|
def _spy(theme_name, widget_kind, palette_rgb):
|
||||||
|
calls.append((theme_name, widget_kind))
|
||||||
|
return real_resolve(theme_name, widget_kind, palette_rgb)
|
||||||
|
|
||||||
|
monkeypatch.setattr(theme_tokens, "resolve_theme", _spy)
|
||||||
|
monkeypatch.setattr(html_render, "theme_tokens", theme_tokens)
|
||||||
|
|
||||||
|
daily = {"2026-07-31": {"high": 75, "low": 55, "category": "clear"}}
|
||||||
|
monkeypatch.setattr(widgets.weather, "get_or_refresh_weather_widget_data", lambda db, frame, widget: daily)
|
||||||
|
_stub_render_html_to_image(monkeypatch)
|
||||||
|
|
||||||
|
frame, widget = _make_widget(db_session, mode="daily", city_label="Denver", render_style="modern")
|
||||||
|
frame.theme = "terracotta"
|
||||||
|
widgets.weather.render(db_session, frame, widget, 200, 160)
|
||||||
|
|
||||||
|
assert ("terracotta", "weather") in calls
|
||||||
|
|||||||
@@ -16,13 +16,14 @@ from app import widgets
|
|||||||
from app.models import Frame, Widget, WhiteboardWidgetConfig
|
from app.models import Frame, Widget, WhiteboardWidgetConfig
|
||||||
|
|
||||||
|
|
||||||
def _make_widget(db_session) -> tuple[Frame, Widget]:
|
def _make_widget(db_session, render_style="classic") -> tuple[Frame, Widget]:
|
||||||
frame = db_session.get(Frame, 1)
|
frame = db_session.get(Frame, 1)
|
||||||
widget = Widget(frame_id=frame.id, widget_type="whiteboard", x=0, y=0, w=8, h=5,
|
widget = Widget(frame_id=frame.id, widget_type="whiteboard", x=0, y=0, w=8, h=5,
|
||||||
sort_order=0, created_at=time.time())
|
sort_order=0, created_at=time.time())
|
||||||
db_session.add(widget)
|
db_session.add(widget)
|
||||||
db_session.flush()
|
db_session.flush()
|
||||||
db_session.add(WhiteboardWidgetConfig(widget_id=widget.id, url="https://example.com/board.whiteboard"))
|
db_session.add(WhiteboardWidgetConfig(widget_id=widget.id, url="https://example.com/board.whiteboard",
|
||||||
|
render_style=render_style))
|
||||||
db_session.commit()
|
db_session.commit()
|
||||||
return frame, widget
|
return frame, widget
|
||||||
|
|
||||||
@@ -83,3 +84,20 @@ def test_both_buttons_map_to_check_now():
|
|||||||
"""No real "next"/"back" concept for a static board -- both physical
|
"""No real "next"/"back" concept for a static board -- both physical
|
||||||
buttons mean the same thing for a whiteboard widget."""
|
buttons mean the same thing for a whiteboard widget."""
|
||||||
assert set(widgets.whiteboard.ACTIONS) == {"check_now"}
|
assert set(widgets.whiteboard.ACTIONS) == {"check_now"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_modern_style(db_session, monkeypatch):
|
||||||
|
from app import html_render
|
||||||
|
|
||||||
|
frame, widget = _make_widget(db_session, render_style="modern")
|
||||||
|
monkeypatch.setattr(widgets.whiteboard, "get_or_refresh_whiteboard_for_widget",
|
||||||
|
lambda db, frame, widget: _tiny_png())
|
||||||
|
|
||||||
|
def _stub(html, target_w, target_h):
|
||||||
|
return Image.new("RGB", (target_w, target_h), (255, 255, 255))
|
||||||
|
|
||||||
|
monkeypatch.setattr(html_render, "render_html_to_image", _stub)
|
||||||
|
|
||||||
|
img = widgets.whiteboard.render(db_session, frame, widget, 300, 200)
|
||||||
|
assert img.size == (300, 200)
|
||||||
|
assert img.mode == "RGB"
|
||||||
|
|||||||
Reference in New Issue
Block a user