2 Commits
Author SHA1 Message Date
Thomas Faour 3735c5bfa7 Add a text widget (rich text: bold/italic/underline, per-run color/highlight)
Build and push server image / test (push) Successful in 27s
Build and push server image / build-and-push (push) Successful in 1m59s
Build and push server image / deploy (push) Successful in 1m9s
A new self-contained widget type showing user-authored rich text -- no
live upstream to poll, like the static image widget, just word-wrapped
styled text instead of an uploaded image.

The dialog's contenteditable HTML is never stored or replayed as HTML:
app/text_content.py parses it server-side (on save) into a plain
paragraphs-of-styled-runs structure -- the actual sanitization
boundary, since raw HTML never round-trips back into any browser DOM
(the dialog rebuilds its editor from that same JSON via
createElement/textContent). app/widgets/text.py renders it with a
custom word-wrap/shrink-to-fit layout, using real vendored font weights
(app/fonts/NotoSans-{Regular,Bold,Italic,BoldItalic}.ttf, OFL-licensed
like the emoji fonts already there) rather than every other widget's
single ImageFont.load_default() -- the one widget type where that
distinction matters.
2026-07-25 14:21:52 +00:00
Thomas Faour f1fda9bdee Move make-widget/run-server skills to root .claude/skills/
Nested .claude/skills/ dirs (previously under server/) are only
auto-discovered on-demand once a file under that subdirectory is
touched, so /make-widget and /run-server weren't invocable from a
fresh session. Root .claude/skills/ is scanned at session start.

Fixes setup.sh/start-server.sh's relative cd-depth math (was hardcoded
for the old server/.claude/skills/run-server/ depth) to instead
resolve the repo root via git and cd into server/ explicitly, and
updates SKILL.md/driver.py's path references to match the new layout.
2026-07-25 12:09:39 +00:00
31 changed files with 1075 additions and 21 deletions
@@ -14,7 +14,12 @@ This container ships with **no Python, Node, Docker, or browser, and
no sudo**. `setup.sh` bootstraps everything non-root; it's the bulk of no sudo**. `setup.sh` bootstraps everything non-root; it's the bulk of
what makes this skill non-obvious. Run it once per fresh container. what makes this skill non-obvious. Run it once per fresh container.
All paths below are relative to `server/`. Commands below (`setup.sh`, `start-server.sh`, `stop-server.sh`,
`env.sh`, `driver.py`) are invoked from the **repo root** via their
`.claude/skills/run-server/` path -- the scripts `cd` into `server/`
themselves. Anything under `.venv/` (the venv itself, `pytest`,
`uvicorn`) lives inside `server/`, so those commands need `server/`
prefixed or `cd server` first.
## Prerequisites ## Prerequisites
@@ -65,7 +70,7 @@ the pane -- source `env.sh` before the first `tmux` call:
source .claude/skills/run-server/env.sh source .claude/skills/run-server/env.sh
tmux new-session -d -s runserver -x 200 -y 50 tmux new-session -d -s runserver -x 200 -y 50
tmux send-keys -t runserver \ tmux send-keys -t runserver \
'source .claude/skills/run-server/env.sh && .venv/bin/python .claude/skills/run-server/driver.py' Enter 'source .claude/skills/run-server/env.sh && server/.venv/bin/python .claude/skills/run-server/driver.py' Enter
timeout 20 bash -c 'until tmux capture-pane -t runserver -p | grep -q "driver>"; do sleep 0.3; done' timeout 20 bash -c 'until tmux capture-pane -t runserver -p | grep -q "driver>"; do sleep 0.3; done'
# Every fresh scratch DB starts with no users -- bootstrap-admin # Every fresh scratch DB starts with no users -- bootstrap-admin
@@ -143,7 +148,7 @@ isn't needed for most UI testing.
## Test ## Test
```bash ```bash
.venv/bin/pytest cd server && .venv/bin/pytest
``` ```
Uses its own tempfile SQLite per run (`tests/conftest.py`) -- no setup Uses its own tempfile SQLite per run (`tests/conftest.py`) -- no setup
@@ -10,8 +10,8 @@ eval/console-errors).
Requires setup.sh to have run first (Python venv + Playwright Chromium Requires setup.sh to have run first (Python venv + Playwright Chromium
+ the non-root shared-lib/font extraction). Run via: + the non-root shared-lib/font extraction). Run via:
server/.claude/skills/run-server/env.sh sourced, then .claude/skills/run-server/env.sh sourced, then
.venv/bin/python server/.claude/skills/run-server/driver.py server/.venv/bin/python .claude/skills/run-server/driver.py
See SKILL.md for the full agent-path invocation (tmux wrapping etc). See SKILL.md for the full agent-path invocation (tmux wrapping etc).
""" """
@@ -4,7 +4,7 @@
# container that ships with NO Python/Node/Docker/browser and NO sudo. # container that ships with NO Python/Node/Docker/browser and NO sudo.
# Re-run any time; every step checks whether it already happened. # Re-run any time; every step checks whether it already happened.
set -euo pipefail set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"/../../.. # -> server/ cd "$(git -C "$(dirname "${BASH_SOURCE[0]}")" rev-parse --show-toplevel)/server"
UV_BIN="$HOME/.local/bin/uv" UV_BIN="$HOME/.local/bin/uv"
DEPS_ROOT="/tmp/run-server-chromium-deps" DEPS_ROOT="/tmp/run-server-chromium-deps"
@@ -3,7 +3,7 @@
# real deployment's data (see CLAUDE.md). Waits for readiness, prints # real deployment's data (see CLAUDE.md). Waits for readiness, prints
# the PID and log path. Usage: ./start-server.sh [scratch-dir] [port] # the PID and log path. Usage: ./start-server.sh [scratch-dir] [port]
set -euo pipefail set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"/../../.. # -> server/ cd "$(git -C "$(dirname "${BASH_SOURCE[0]}")" rev-parse --show-toplevel)/server"
SCRATCH="${1:-/tmp/run-server-scratch}" SCRATCH="${1:-/tmp/run-server-scratch}"
PORT="${2:-8420}" PORT="${2:-8420}"
+1 -1
View File
@@ -54,7 +54,7 @@ Start here, don't re-derive from scratch:
live (mobile viewport CSS collapse, a dialog's status message landing live (mobile viewport CSS collapse, a dialog's status message landing
behind its own backdrop, a JSON/form-urlencoded body mismatch). Spin up behind its own backdrop, a JSON/form-urlencoded body mismatch). Spin up
`uvicorn app.main:app` against a scratch `DATABASE_URL`/`CONFIG_PATH` `uvicorn app.main:app` against a scratch `DATABASE_URL`/`CONFIG_PATH`
sqlite file, don't touch the real deployment's data. `server/.claude/skills/run-server/` sqlite file, don't touch the real deployment's data. `.claude/skills/run-server/`
(`/run-server`) has a driver for exactly this. (`/run-server`) has a driver for exactly this.
- **New/changed UI must work at both desktop and mobile widths -- - **New/changed UI must work at both desktop and mobile widths --
screenshot both, don't assume one implies the other.** The layout screenshot both, don't assume one implies the other.** The layout
+14 -9
View File
@@ -1,8 +1,8 @@
# Widget system # Widget system
A frame's panel isn't one fixed "mode" anymore -- it holds N independently A frame's panel isn't one fixed "mode" anymore -- it holds N independently
placed/sized widgets (photos/calendar/whiteboard/tasks/static image), like arranging placed/sized widgets (photos/calendar/whiteboard/tasks/static image/text), like
icons on an Android home screen. A frame can hold several widgets of the arranging icons on an Android home screen. A frame can hold several widgets of the
same type (e.g. two photo widgets pointed at different Immich albums side same type (e.g. two photo widgets pointed at different Immich albums side
by side). by side).
This replaced an earlier design where `Frame.mode` picked exactly one This replaced an earlier design where `Frame.mode` picked exactly one
@@ -20,16 +20,20 @@ a button press does.
## Data model ## Data model
- `Widget` (`server/app/models.py`): `id`, `frame_id`, `widget_type` - `Widget` (`server/app/models.py`): `id`, `frame_id`, `widget_type`
(`"photos"` | `"calendar"` | `"whiteboard"` | `"tasks"` | `"static"`), `x`/`y`/`w`/`h` (`"photos"` | `"calendar"` | `"whiteboard"` | `"tasks"` | `"static"` | `"text"`), `x`/`y`/`w`/`h`
(grid cells), `sort_order`. Widgets never overlap (enforced server-side in (grid cells), `sort_order`. Widgets never overlap (enforced server-side in
`routers/api_widgets.py`, re-validated regardless of what the client `routers/api_widgets.py`, re-validated regardless of what the client
already checked) -- that's what keeps compositing simple: no z-order, already checked) -- that's what keeps compositing simple: no z-order,
no blending, just N independent regions pasted onto one shared canvas. no blending, just N independent regions pasted onto one shared canvas.
- Per-type 1:1 extension tables -- `PhotoWidgetConfig`, - Per-type 1:1 extension tables -- `PhotoWidgetConfig`,
`CalendarWidgetConfig`, `WhiteboardWidgetConfig`, `TaskWidgetConfig`, `CalendarWidgetConfig`, `WhiteboardWidgetConfig`, `TaskWidgetConfig`,
(plus `StaticWidgetConfig`) each keyed by `widget_id` with `StaticWidgetConfig`, `TextWidgetConfig`, each keyed by `widget_id` with
`ondelete="CASCADE"` -- rather than one wide table with every type's `ondelete="CASCADE"` -- rather than one wide table with every type's
mostly-irrelevant columns. `PhotoWidgetConfig` mostly-irrelevant columns. `TextWidgetConfig.content` is parsed rich
text (paragraphs of styled runs), never raw HTML -- see
`server/app/text_content.py`'s module docstring for why that parse
step is the widget's actual stored-XSS sanitization boundary.
`PhotoWidgetConfig`
mirrors `app/photo_queue.py`'s attribute names exactly, so that module's mirrors `app/photo_queue.py`'s attribute names exactly, so that module's
advance/back/queue logic ports across widget instances unchanged. advance/back/queue logic ports across widget instances unchanged.
`TaskWidgetConfig` used to be a handful of `tasks_*` columns bolted onto `TaskWidgetConfig` used to be a handful of `tasks_*` columns bolted onto
@@ -61,7 +65,7 @@ orientation change rather than trying to remap coordinates.
Each widget type has a minimum grid footprint (`grid.MIN_FOOTPRINT`): Each widget type has a minimum grid footprint (`grid.MIN_FOOTPRINT`):
photos 1x1, calendar 3x2 (a crammed calendar is illegible regardless of photos 1x1, calendar 3x2 (a crammed calendar is illegible regardless of
size-tier scaling), whiteboard 2x2, tasks 2x2, static image 1x1. size-tier scaling), whiteboard 2x2, tasks 2x2, static image 1x1, text 2x1.
Enforced both client-side Enforced both client-side
(UX, in the Layout tab's drag/resize canvas -- `static/frame_layout.js`) (UX, in the Layout tab's drag/resize canvas -- `static/frame_layout.js`)
and server-side (`routers/api_widgets.py`) -- the client is never trusted and server-side (`routers/api_widgets.py`) -- the client is never trusted
@@ -71,7 +75,7 @@ alone.
`app/widgets/` is the render/action registry -- one module per `app/widgets/` is the render/action registry -- one module per
`widget_type` (`photos.py`, `calendar.py`, `whiteboard.py`, `tasks.py`, `widget_type` (`photos.py`, `calendar.py`, `whiteboard.py`, `tasks.py`,
`static_image.py`), each exposing: `static_image.py`, `text.py`), each exposing:
- `render(db, frame, widget, target_w, target_h, is_normal_wake) -> Image`: - `render(db, frame, widget, target_w, target_h, is_normal_wake) -> Image`:
an unquantized RGB image exactly `target_w x target_h`, the widget's an unquantized RGB image exactly `target_w x target_h`, the widget's
@@ -81,8 +85,9 @@ alone.
bad moment doesn't blank the whole panel. bad moment doesn't blank the whole panel.
- `ACTIONS: dict[str, Callable]` -- named button actions this type - `ACTIONS: dict[str, Callable]` -- named button actions this type
supports (`"advance"`/`"back"` for photos and calendar, `"check_now"` supports (`"advance"`/`"back"` for photos and calendar, `"check_now"`
for whiteboard). Empty for tasks and static image -- nothing to for whiteboard). Empty for tasks, static image, and text -- nothing to
advance/back/force for a passive checklist or a fixed uploaded image. advance/back/force for a passive checklist, a fixed uploaded image, or
a fixed block of authored text.
- `ACTION_LABELS: dict[str, str]` -- human labels for the button- - `ACTION_LABELS: dict[str, str]` -- human labels for the button-
assignment UI. assignment UI.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -31,6 +31,7 @@ MIN_FOOTPRINT: dict[str, tuple[int, int]] = {
"whiteboard": (2, 2), "whiteboard": (2, 2),
"tasks": (2, 2), "tasks": (2, 2),
"static": (1, 1), "static": (1, 1),
"text": (2, 1),
} }
Rect = tuple[int, int, int, int] # (x, y, w, h) Rect = tuple[int, int, int, int] # (x, y, w, h)
+9
View File
@@ -511,6 +511,14 @@ def _migration_20(conn) -> None:
Base.metadata.create_all(bind=conn) Base.metadata.create_all(bind=conn)
def _migration_21(conn) -> None:
"""New widget type: a text widget shows user-authored rich text (see
app/text_content.py, app/widgets/text.py, models.TextWidgetConfig)
-- another no-live-upstream type like migration 20's static image.
Same brand-new-table create_all shape."""
Base.metadata.create_all(bind=conn)
MIGRATIONS = [ MIGRATIONS = [
(1, _migration_1), (1, _migration_1),
(2, _migration_2), (2, _migration_2),
@@ -532,6 +540,7 @@ MIGRATIONS = [
(18, _migration_18), (18, _migration_18),
(19, _migration_19), (19, _migration_19),
(20, _migration_20), (20, _migration_20),
(21, _migration_21),
] ]
+32
View File
@@ -553,6 +553,37 @@ class WhiteboardWidgetConfig(Base):
cached_image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True) cached_image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
class TextWidgetConfig(Base):
"""One text widget's authored content + display settings -- another
no-live-upstream type like StaticWidgetConfig, just parsed rich text
instead of an uploaded image. content is never raw HTML: the
dialog's contenteditable innerHTML is parsed server-side (see
app/text_content.py, the sanitization boundary) into this plain
run structure at save time, so render() (app/widgets/text.py) never
re-parses/sanitizes HTML on every panel refresh, and the dialog never
re-injects stored HTML via innerHTML when reopened.
[[{"text","bold","italic","underline","color","bg"}, ...], ...] --
outer list is paragraphs (line breaks), inner list is styled runs
within that paragraph. color/bg are "#rrggbb" or null (falls back to
black text / no highlight). NULL (not just []) means never
configured, matching StaticWidgetConfig.image's None-vs-empty
convention for "not configured yet"."""
__tablename__ = "text_widget_configs"
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True)
content: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
# Base point size for the whole block -- render() shrinks this down
# (never up) to fit the widget's actual box; per-run font size isn't
# supported, only the bold/italic/underline/color/bg style flags are
# per-run (see app/text_content.py) -- keeps the wrap/shrink-to-fit
# layout in app/widgets/text.py to one size per render pass.
font_size: Mapped[int] = mapped_column(Integer, default=28)
align: Mapped[str] = mapped_column(String, default="left") # "left" | "center" | "right"
background_color: Mapped[str] = mapped_column(String, default="#ffffff")
class StaticWidgetConfig(Base): class StaticWidgetConfig(Base):
"""One static-image widget's uploaded content + display settings -- """One static-image widget's uploaded content + display settings --
unlike every other widget type, this one has no live upstream to unlike every other widget type, this one has no live upstream to
@@ -583,6 +614,7 @@ WIDGET_CONFIG_MODELS: dict[str, type] = {
"whiteboard": WhiteboardWidgetConfig, "whiteboard": WhiteboardWidgetConfig,
"tasks": TaskWidgetConfig, "tasks": TaskWidgetConfig,
"static": StaticWidgetConfig, "static": StaticWidgetConfig,
"text": TextWidgetConfig,
} }
+45
View File
@@ -33,6 +33,7 @@ from ..image_pipeline import (
DEFAULT_DISPLAY_MODE, DEFAULT_DISPLAY_MODE,
DEFAULT_STATIC_DISPLAY_MODE, DEFAULT_STATIC_DISPLAY_MODE,
DISPLAY_MODES, DISPLAY_MODES,
hex_to_rgb,
STATIC_DISPLAY_MODES, STATIC_DISPLAY_MODES,
render_preview_png, render_preview_png,
) )
@@ -45,11 +46,14 @@ from ..models import (
PhotoWidgetConfig, PhotoWidgetConfig,
StaticWidgetConfig, StaticWidgetConfig,
TaskWidgetConfig, TaskWidgetConfig,
TextWidgetConfig,
WhiteboardWidgetConfig, WhiteboardWidgetConfig,
WIDGET_CONFIG_MODELS, WIDGET_CONFIG_MODELS,
Widget, Widget,
) )
from ..text_content import has_text, parse_rich_text
from ..widgets import WIDGET_TYPES from ..widgets import WIDGET_TYPES
from ..widgets import text as text_widget
from .common import ( from .common import (
calendar_sources_for_widget, calendar_sources_for_widget,
fetch_source_and_faces, fetch_source_and_faces,
@@ -69,6 +73,8 @@ router = APIRouter()
MIN_QUEUE_TARGET_LEN = 5 MIN_QUEUE_TARGET_LEN = 5
MAX_QUEUE_TARGET_LEN = 5000 MAX_QUEUE_TARGET_LEN = 5000
MIN_TEXT_FONT_SIZE = 10
MAX_TEXT_FONT_SIZE = 96
CALENDAR_COLOR_INDEX_RANGE = range(2, 6) # Yellow/Red/Blue/Green -- see PALETTE_LABELS CALENDAR_COLOR_INDEX_RANGE = range(2, 6) # Yellow/Red/Blue/Green -- see PALETTE_LABELS
MAX_TASKS_NAME_LEN = 40 # a sane on-panel-header length, see calendar_render._draw_tasks MAX_TASKS_NAME_LEN = 40 # a sane on-panel-header length, see calendar_render._draw_tasks
@@ -271,6 +277,11 @@ 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),
# text
text_html: str | None = Form(None),
text_font_size: int | None = Form(None),
text_align: str | None = Form(None),
text_background_color: 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
@@ -347,6 +358,21 @@ def api_widget_config_save(
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
elif widget.widget_type == "text":
with widget_locked(db, frame.id, widget.id) as (_, _, xcfg):
if text_html is not None:
# The one save path that touches app/text_content.py --
# see its module docstring for why parsing (not storing
# raw HTML) is the actual sanitization boundary here.
xcfg.content = parse_rich_text(text_html)
if text_font_size is not None:
xcfg.font_size = max(MIN_TEXT_FONT_SIZE, min(MAX_TEXT_FONT_SIZE, text_font_size))
if text_align is not None:
xcfg.align = text_align if text_align in ("left", "center", "right") else "left"
if text_background_color is not None:
xcfg.background_color = (
text_background_color if hex_to_rgb(text_background_color) else "#ffffff"
)
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"}
@@ -852,6 +878,25 @@ def api_widget_preview_static(
return Response(content=png, media_type="image/png") return Response(content=png, media_type="image/png")
# --- Text: preview ----------------------------------------------------------
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/preview/text")
def api_widget_preview_text(
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), db: Session = Depends(get_db)
):
"""The saved rich text run through the same word-wrap/shrink-to-fit
layout and quantize pass a live device render would use -- same
"reflects what's currently saved" convention as the other preview
endpoints."""
frame, widget = frame_widget
_require_widget_type(widget, "text")
xcfg = db.get(TextWidgetConfig, widget.id)
if not has_text(xcfg.content):
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)
return Response(content=png, media_type="image/png")
# --- Whiteboard: source/preview ------------------------------------------ # --- Whiteboard: source/preview ------------------------------------------
class WhiteboardSourceRequest(BaseModel): class WhiteboardSourceRequest(BaseModel):
+7
View File
@@ -35,6 +35,7 @@ from ..models import (
PhotoWidgetConfig, PhotoWidgetConfig,
StaticWidgetConfig, StaticWidgetConfig,
TaskWidgetConfig, TaskWidgetConfig,
TextWidgetConfig,
User, User,
UserFrame, UserFrame,
WhiteboardWidgetConfig, WhiteboardWidgetConfig,
@@ -245,6 +246,12 @@ def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session =
"display_mode_labels": {k: v for k, v in DISPLAY_MODE_LABELS.items() if k in STATIC_DISPLAY_MODES}, "display_mode_labels": {k: v for k, v in DISPLAY_MODE_LABELS.items() if k in STATIC_DISPLAY_MODES},
}) })
if widget.widget_type == "text":
text_cfg = db.get(TextWidgetConfig, widget.id)
return templates.TemplateResponse("_widget_dialog_text.html", {
"request": request, "frame": frame, "widget": widget, "text_cfg": text_cfg,
})
if widget.widget_type == "whiteboard": if widget.widget_type == "whiteboard":
whiteboard_cfg = db.get(WhiteboardWidgetConfig, widget.id) whiteboard_cfg = db.get(WhiteboardWidgetConfig, widget.id)
viewer_has_webdav_creds = bool( viewer_has_webdav_creds = bool(
+1 -1
View File
@@ -62,7 +62,7 @@
// 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 = {
photos: 'Photos', calendar: 'Calendar', whiteboard: 'Whiteboard (alpha)', tasks: 'Tasks', photos: 'Photos', calendar: 'Calendar', whiteboard: 'Whiteboard (alpha)', tasks: 'Tasks',
static: 'Static image', static: 'Static image', text: 'Text',
}; };
function showStatus(ok, message) { function showStatus(ok, message) {
+2 -2
View File
@@ -286,11 +286,11 @@ window.addEventListener('resize', () => {
// icon was clicked). // icon was clicked).
const DIALOG_INIT = { const DIALOG_INIT = {
photos: initPhotosDialog, calendar: initCalendarDialog, whiteboard: initWhiteboardDialog, photos: initPhotosDialog, calendar: initCalendarDialog, whiteboard: initWhiteboardDialog,
tasks: initTasksDialog, static: initStaticDialog, tasks: initTasksDialog, static: initStaticDialog, text: initTextDialog,
}; };
const DIALOG_CLOSE = { const DIALOG_CLOSE = {
photos: closePhotosDialog, calendar: closeCalendarDialog, whiteboard: closeWhiteboardDialog, photos: closePhotosDialog, calendar: closeCalendarDialog, whiteboard: closeWhiteboardDialog,
tasks: closeTasksDialog, static: closeStaticDialog, tasks: closeTasksDialog, static: closeStaticDialog, text: closeTextDialog,
}; };
let openDialogWidgetType = null; let openDialogWidgetType = null;
+49
View File
@@ -205,6 +205,12 @@ details.card .sub { margin-top: 8px; }
color: var(--text-muted); color: var(--text-muted);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
input[type="color"] {
width: 44px;
height: 34px;
padding: 2px;
cursor: pointer;
}
input[type="range"] { input[type="range"] {
width: 100%; width: 100%;
margin-top: 8px; margin-top: 8px;
@@ -281,6 +287,49 @@ input:focus, select:focus {
.checkbox-row input { width: auto; margin-top: 0; } .checkbox-row input { width: auto; margin-top: 0; }
.checkbox-row label { margin-top: 0; font-weight: normal; } .checkbox-row label { margin-top: 0; font-weight: normal; }
.richtext-toolbar { display: flex; align-items: center; gap: 4px; margin-top: 12px; flex-wrap: wrap; }
.richtext-btn {
width: auto;
min-width: 32px;
padding: 5px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface-alt);
color: var(--text);
font-size: 14px;
cursor: pointer;
}
.richtext-btn:hover { background: var(--surface); }
.richtext-toolbar-sep { width: 1px; align-self: stretch; background: var(--border); margin: 0 4px; }
.richtext-color-label {
display: inline-flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: normal;
font-size: 14px;
color: var(--text-muted);
cursor: pointer;
}
.richtext-color-label input[type="color"] {
width: 22px;
height: 22px;
padding: 0;
margin-top: 0;
}
.richtext-editor {
margin-top: 8px;
min-height: 90px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface-alt);
color: var(--text);
font-size: 15px;
line-height: 1.5;
}
.richtext-editor:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
.calendar-user-list { list-style: none; margin: 12px 0 0; padding: 0; } .calendar-user-list { list-style: none; margin: 12px 0 0; padding: 0; }
.calendar-user-list > li { margin-top: 14px; } .calendar-user-list > li { margin-top: 14px; }
.calendar-user-list > li:first-child { margin-top: 0; } .calendar-user-list > li:first-child { margin-top: 0; }
+140
View File
@@ -0,0 +1,140 @@
// Text widget dialog: a small rich-text editor (bold/italic/underline,
// text/highlight color), font size/alignment/background settings, and
// the rendered preview. Not a page-load script -- frame_layout.js
// fetches this widget's dialog HTML fragment, injects it into the
// shared <dialog>, points window.FRAME_API at this specific widget
// (/api/frames/{id}/widgets/{widget_id}), then calls initTextDialog().
//
// The editor is never seeded via innerHTML string interpolation --
// #text-editor's data-content attribute (server-rendered from
// app/text_content.py's already-sanitized run structure, not raw HTML)
// is JSON-parsed and rebuilt with createElement/textContent below. The
// same <div><span style="..."> shape this produces is exactly what
// app/text_content.py's parser expects back on save, so round-tripping
// (load -> edit -> save -> reload) is stable.
function loadTextPreview() {
document.getElementById('text-preview').src = `${window.FRAME_API}/preview/text?_=${Date.now()}`;
}
function buildTextEditorContent(editor, paragraphs) {
editor.textContent = '';
if (!paragraphs || !paragraphs.length) return;
paragraphs.forEach((para) => {
const div = document.createElement('div');
if (!para.length) {
div.appendChild(document.createElement('br'));
} else {
para.forEach((run) => {
const span = document.createElement('span');
span.textContent = run.text;
if (run.bold) span.style.fontWeight = 'bold';
if (run.italic) span.style.fontStyle = 'italic';
if (run.underline) span.style.textDecoration = 'underline';
if (run.color) span.style.color = run.color;
if (run.bg) span.style.backgroundColor = run.bg;
div.appendChild(span);
});
}
editor.appendChild(div);
});
}
let _textSavedRange = null;
let _textSelectionHandler = null;
function initTextDialog() {
const editor = document.getElementById('text-editor');
let initialContent = null;
try {
initialContent = JSON.parse(editor.dataset.content || 'null');
} catch (e) { /* leave empty */ }
buildTextEditorContent(editor, initialContent);
// Native <input type="color"> steals focus (and with it, the
// editor's text selection) the moment it's interacted with -- track
// the most recent in-editor selection continuously so a color pick
// can be reapplied to the text the user actually had selected,
// instead of applying to nothing.
_textSelectionHandler = () => {
const sel = window.getSelection();
if (sel.rangeCount > 0 && editor.contains(sel.anchorNode)) {
_textSavedRange = sel.getRangeAt(0).cloneRange();
}
};
document.addEventListener('selectionchange', _textSelectionHandler);
function restoreSelection() {
if (!_textSavedRange) return;
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(_textSavedRange);
}
['text-bold', 'text-italic', 'text-underline'].forEach((id) => {
const btn = document.getElementById(id);
// preventDefault on mousedown keeps focus (and the selection) in
// the editor, so the click's execCommand has something to act on.
btn.addEventListener('mousedown', (e) => e.preventDefault());
btn.addEventListener('click', () => {
editor.focus();
document.execCommand(btn.dataset.cmd, false, null);
});
});
document.getElementById('text-color').addEventListener('input', (e) => {
editor.focus();
restoreSelection();
document.execCommand('foreColor', false, e.target.value);
});
document.getElementById('text-highlight').addEventListener('input', (e) => {
editor.focus();
restoreSelection();
document.execCommand('hiliteColor', false, e.target.value);
});
document.getElementById('text-highlight-clear').addEventListener('mousedown', (e) => e.preventDefault());
document.getElementById('text-highlight-clear').addEventListener('click', () => {
editor.focus();
restoreSelection();
document.execCommand('hiliteColor', false, 'transparent');
});
document.getElementById('text_font_size').addEventListener('input', (e) => {
document.getElementById('text_font_size_value').textContent = `${e.target.value}px`;
});
document.getElementById('text-config-form').addEventListener('submit', async (e) => {
e.preventDefault();
const body = new URLSearchParams({
text_html: editor.innerHTML,
text_font_size: document.getElementById('text_font_size').value,
text_align: document.getElementById('text_align').value,
text_background_color: document.getElementById('text_background_color').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.');
loadTextPreview();
} catch (e2) {
showStatus(false, e2.message);
}
});
document.getElementById('text-preview-refresh').addEventListener('click', loadTextPreview);
loadTextPreview();
}
function closeTextDialog() {
if (_textSelectionHandler) {
document.removeEventListener('selectionchange', _textSelectionHandler);
_textSelectionHandler = null;
}
_textSavedRange = null;
}
@@ -0,0 +1,48 @@
<h2 class="dialog-title">Text widget</h2>
<section class="card">
<h2 class="card-title">Text</h2>
<p class="sub">A short block of styled text -- select some and use the
toolbar, or just type. Saved as plain text plus style flags, not raw
HTML.</p>
<div class="richtext-toolbar" id="text-toolbar">
<button type="button" class="richtext-btn" id="text-bold" title="Bold" data-cmd="bold"><b>B</b></button>
<button type="button" class="richtext-btn" id="text-italic" title="Italic" data-cmd="italic"><i>I</i></button>
<button type="button" class="richtext-btn" id="text-underline" title="Underline" data-cmd="underline"><u>U</u></button>
<span class="richtext-toolbar-sep"></span>
<label class="richtext-color-label" title="Text color">A<input type="color" id="text-color" value="#000000"></label>
<label class="richtext-color-label" title="Highlight color">&#9635;<input type="color" id="text-highlight" value="#ffff00"></label>
<button type="button" class="richtext-btn" id="text-highlight-clear" title="Remove highlight">&times;</button>
</div>
<div class="richtext-editor" id="text-editor" contenteditable="true"
data-content='{{ (text_cfg.content if text_cfg else none) | tojson }}'></div>
</section>
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Settings</h2>
<form id="text-config-form">
<label>Font size
<input type="range" id="text_font_size" min="10" max="96" step="2"
value="{{ text_cfg.font_size if text_cfg else 28 }}">
<span class="slider-value" id="text_font_size_value">{{ text_cfg.font_size if text_cfg else 28 }}px</span>
</label>
<label>Alignment
<select id="text_align">
{% for value, label in [("left", "Left"), ("center", "Center"), ("right", "Right")] %}
<option value="{{ value }}" {% if text_cfg and text_cfg.align == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<label>Background color
<input type="color" id="text_background_color" value="{{ text_cfg.background_color if text_cfg else '#ffffff' }}">
</label>
<button type="submit">Save</button>
</form>
</section>
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Preview</h2>
<p class="sub">How this widget currently renders.</p>
<img class="preview-img" id="text-preview" alt="Text widget preview">
<button type="button" class="secondary" id="text-preview-refresh">Refresh now</button>
</section>
+1
View File
@@ -60,5 +60,6 @@
<script src="/static/widget_dialog_whiteboard.js"></script> <script src="/static/widget_dialog_whiteboard.js"></script>
<script src="/static/widget_dialog_tasks.js"></script> <script src="/static/widget_dialog_tasks.js"></script>
<script src="/static/widget_dialog_static.js"></script> <script src="/static/widget_dialog_static.js"></script>
<script src="/static/widget_dialog_text.js"></script>
<script src="/static/frame_layout.js"></script> <script src="/static/frame_layout.js"></script>
{% endblock %} {% endblock %}
+169
View File
@@ -0,0 +1,169 @@
"""Parses a contenteditable div's serialized innerHTML (widget_dialog_
text.js's POSTed content_html) into a plain, storage-safe run structure
-- list of paragraphs, each a list of {"text", "bold", "italic",
"underline", "color", "bg"} runs -- for the text widget (see
models.TextWidgetConfig, app/widgets/text.py).
This is the sanitization boundary the checklist's stored-XSS note
(CLAUDE.md, another linked user could have set this) is about: raw HTML
never round-trips back into any browser DOM. Only text content and a
small fixed set of style flags survive parsing; every tag, attribute,
and CSS property not explicitly recognized below is simply discarded --
there's no allowlist-of-tags-to-keep-as-HTML step where something could
slip through unescaped, because nothing is ever re-emitted as HTML at
all. The dialog reconstructs its editor from this same run structure via
safe DOM calls (createElement/textContent), never innerHTML."""
from __future__ import annotations
import re
from html.parser import HTMLParser
# Generous ceilings, not exact UX limits -- just stop a direct API call
# (bypassing the dialog's own textarea-ish size) from storing something
# pathologically large. MAX_INPUT_CHARS bounds parse work; MAX_TOTAL_CHARS
# bounds what's actually kept (a widget's on-panel region is a few
# hundred pixels -- there is no legible use for more than a few thousand
# characters of body text there).
MAX_INPUT_CHARS = 200_000
MAX_TOTAL_CHARS = 4_000
_BASE_STYLE = {"bold": False, "italic": False, "underline": False, "color": None, "bg": None}
_BLOCK_TAGS = {"div", "p", "li"}
_VOID_TAGS = {"br"}
_HEX6 = re.compile(r"^#([0-9a-fA-F]{6})$")
_HEX3 = re.compile(r"^#([0-9a-fA-F]{3})$")
_RGB = re.compile(r"^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(?:,\s*[\d.]+\s*)?\)$")
_STYLE_PROP = re.compile(r"([a-zA-Z-]+)\s*:\s*([^;]+)")
_BOLD_WEIGHTS = {"bold", "bolder", "600", "700", "800", "900"}
def _normalize_color(value: str) -> str | None:
""""#1a2b3c" / "#abc" / "rgb(26, 43, 60)" -> "#1a2b3c". Anything else
(a CSS named color, "transparent", garbage) -> None, i.e. dropped --
this is the one place an arbitrary style-attribute string could try
to smuggle something through, so it's a strict allowlist match, not
a best-effort parse."""
value = value.strip()
m = _HEX6.match(value)
if m:
return "#" + m.group(1).lower()
m = _HEX3.match(value)
if m:
return "#" + "".join(c * 2 for c in m.group(1)).lower()
m = _RGB.match(value)
if m:
r, g, b = (max(0, min(255, int(x))) for x in m.groups())
return f"#{r:02x}{g:02x}{b:02x}"
return None
class _RichTextParser(HTMLParser):
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.paragraphs: list[list[dict]] = [[]]
self._style_stack: list[dict] = [_BASE_STYLE]
self._at_line_start = True
def _break(self, tag: str) -> None:
# Coalesces contenteditable's per-line block wrapping (Chrome
# wraps every line in its own <div> even without a deliberate
# blank line) down to one paragraph break per actual line gap,
# while still letting an explicit <br> when already at a fresh
# line start (Chrome's "<div><br></div>" idiom for a blank line,
# or a genuine double Shift+Enter) add a real blank paragraph.
if not self._at_line_start:
self.paragraphs.append([])
self._at_line_start = True
elif tag == "br":
self.paragraphs.append([])
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
if tag in _BLOCK_TAGS or tag in _VOID_TAGS:
self._break(tag)
if tag in _VOID_TAGS:
return
style = dict(self._style_stack[-1])
attrs_dict = {k: v for k, v in attrs if v is not None}
if tag in ("b", "strong"):
style["bold"] = True
elif tag in ("i", "em"):
style["italic"] = True
elif tag == "u":
style["underline"] = True
elif tag == "font":
color = _normalize_color(attrs_dict.get("color", ""))
if color:
style["color"] = color
elif tag == "span":
for prop, val in _STYLE_PROP.findall(attrs_dict.get("style", "")):
prop = prop.strip().lower()
val = val.strip()
if prop == "color":
color = _normalize_color(val)
if color:
style["color"] = color
elif prop == "background-color":
color = _normalize_color(val)
if color:
style["bg"] = color
elif prop == "font-weight" and val.lower() in _BOLD_WEIGHTS:
style["bold"] = True
elif prop == "font-style" and val.lower() == "italic":
style["italic"] = True
elif prop == "text-decoration" and "underline" in val.lower():
style["underline"] = True
# Pushed for every non-void tag, including ones with no
# recognized style effect (script/a/img/...) -- keeps push/pop
# balanced against handle_endtag regardless of tag, without
# needing to track which tags actually pushed something.
self._style_stack.append(style)
def handle_endtag(self, tag: str) -> None:
if tag in _VOID_TAGS:
return
if len(self._style_stack) > 1:
self._style_stack.pop()
def handle_data(self, data: str) -> None:
if not data:
return
style = self._style_stack[-1]
self.paragraphs[-1].append({"text": data, **style})
if data.strip():
self._at_line_start = False
def parse_rich_text(html: str) -> list[list[dict]]:
"""The sanitization entry point -- see module docstring. Always
returns a valid (possibly all-empty) paragraphs structure, never
raises for malformed markup (html.parser tolerates unclosed/
mismatched tags; handle_endtag's length guard tolerates an
over-popped stack)."""
parser = _RichTextParser()
parser.feed(html[:MAX_INPUT_CHARS])
parser.close()
paragraphs = parser.paragraphs
total = 0
truncated: list[list[dict]] = []
for para in paragraphs:
new_para: list[dict] = []
for run in para:
remaining = MAX_TOTAL_CHARS - total
if remaining <= 0:
break
text = run["text"][:remaining]
total += len(text)
new_para.append({**run, "text": text})
truncated.append(new_para)
if total >= MAX_TOTAL_CHARS:
break
return truncated
def has_text(paragraphs: list[list[dict]] | None) -> bool:
if not paragraphs:
return False
return any(run["text"].strip() for para in paragraphs for run in para)
+2 -1
View File
@@ -36,7 +36,7 @@ Each module in this package exposes:
from __future__ import annotations from __future__ import annotations
from . import calendar, photos, static_image, tasks, whiteboard from . import calendar, photos, static_image, tasks, text, whiteboard
WIDGET_TYPES = { WIDGET_TYPES = {
"photos": photos, "photos": photos,
@@ -44,4 +44,5 @@ WIDGET_TYPES = {
"whiteboard": whiteboard, "whiteboard": whiteboard,
"tasks": tasks, "tasks": tasks,
"static": static_image, "static": static_image,
"text": text,
} }
+200
View File
@@ -0,0 +1,200 @@
"""Text widget: user-authored rich text (bold/italic/underline, per-run
text/highlight color), composed once in the dialog and rendered on
every panel refresh from the parsed run structure -- no live upstream to
fetch, same self-contained shape as static_image.py, just word-wrapped
text instead of an uploaded image. See app/text_content.py for how the
dialog's contenteditable HTML becomes models.TextWidgetConfig.content
(the sanitization boundary; this module never sees raw HTML).
Bold/italic use real vendored font weights (app/fonts/NotoSans-*.ttf,
OFL-licensed like the emoji fonts already there) rather than every other
widget's single ImageFont.load_default() -- the one widget type where
that distinction is the whole point.
No button actions -- there's nothing to advance/back/check for a fixed
block of authored text."""
from __future__ import annotations
import re
from functools import lru_cache
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
from sqlalchemy.orm import Session
from ..image_pipeline import _quantize, draw_text, hex_to_rgb, logical_render_size
from ..models import Frame, TextWidgetConfig, Widget
from ..text_content import has_text
from ._shared import placeholder_image
ACTIONS: dict = {}
ACTION_LABELS: dict[str, str] = {}
MARGIN = 14
MIN_FONT_SIZE = 10
LINE_HEIGHT_FACTOR = 1.35
DEFAULT_FG = (0, 0, 0)
DEFAULT_BG = (255, 255, 255)
_FONT_DIR = Path(__file__).resolve().parent.parent / "fonts"
_FONT_FILES = {
(False, False): "NotoSans-Regular.ttf",
(True, False): "NotoSans-Bold.ttf",
(False, True): "NotoSans-Italic.ttf",
(True, True): "NotoSans-BoldItalic.ttf",
}
_WORD_OR_SPACE = re.compile(r"\S+|\s+")
@lru_cache(maxsize=128)
def _font(bold: bool, italic: bool, size: int) -> ImageFont.FreeTypeFont:
return ImageFont.truetype(str(_FONT_DIR / _FONT_FILES[(bold, italic)]), size)
def _paragraph_word_groups(paragraph: list[dict]) -> list[list[dict]]:
"""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
(no whitespace between them in the source) -- otherwise bolding part
of a word (e.g. "wor**ld**") would introduce a spurious space at the
style boundary once wrapped. Whitespace runs become the implicit gap
between groups (collapsed to a single space, however many source
characters it was)."""
groups: list[list[dict]] = []
current: list[dict] = []
for run in paragraph:
for piece in _WORD_OR_SPACE.findall(run["text"]):
if piece.isspace():
if current:
groups.append(current)
current = []
else:
current.append({**run, "text": piece})
if current:
groups.append(current)
return groups
def _group_width(draw: ImageDraw.ImageDraw, group: list[dict], size: int) -> float:
return sum(draw.textlength(tok["text"], font=_font(tok["bold"], tok["italic"], size)) for tok in group)
def _wrap_paragraph(draw: ImageDraw.ImageDraw, groups: list[list[dict]], size: int,
max_width: int, space_width: float) -> list[list[list[dict]]]:
"""Greedy word wrap -> list of lines, each a list of word groups.
An empty `groups` (a blank authored line) still produces one empty
line, to preserve the blank line's vertical space."""
lines: list[list[list[dict]]] = []
current: list[list[dict]] = []
current_w = 0.0
for group in groups:
gw = _group_width(draw, group, size)
add_w = gw + (space_width if current else 0)
if current and current_w + add_w > max_width:
lines.append(current)
current = [group]
current_w = gw
else:
current.append(group)
current_w += add_w
if current or not groups:
lines.append(current)
return lines
def _fit(draw: ImageDraw.ImageDraw, paragraphs: list[list[dict]], start_size: int,
max_width: int, max_height: int) -> tuple[int, list[list[list[dict]]]]:
"""Shrinks font size (down to MIN_FONT_SIZE) until the wrapped
content's total height fits max_height, or gives up at the floor --
a too-small widget box just clips rather than raising. Returns the
chosen size and the flat list of lines (each a list of word groups)
across every paragraph, in order."""
size = max(MIN_FONT_SIZE, start_size)
lines: list[list[list[dict]]] = []
while True:
space_width = draw.textlength(" ", font=_font(False, False, size))
lines = []
for paragraph in paragraphs:
lines.extend(_wrap_paragraph(draw, _paragraph_word_groups(paragraph), size, max_width, space_width))
line_h = round(size * LINE_HEIGHT_FACTOR)
total_h = len(lines) * line_h
if total_h <= max_height or size <= MIN_FONT_SIZE:
return size, lines
size = max(MIN_FONT_SIZE, size - 2)
def _draw_line(img: Image.Image, draw: ImageDraw.ImageDraw, line: list[list[dict]], y: int,
size: int, line_h: int, max_width: int, align: str, space_width: float) -> None:
line_width = sum(_group_width(draw, g, size) for g in line) + space_width * max(0, len(line) - 1)
if align == "center":
x = MARGIN + max(0, (max_width - line_width) / 2)
elif align == "right":
x = MARGIN + max(0, max_width - line_width)
else:
x = MARGIN
underline_h = max(1, size // 16)
for gi, group in enumerate(line):
for tok in group:
font = _font(tok["bold"], tok["italic"], size)
w = draw.textlength(tok["text"], font=font)
if tok["bg"]:
bg_rgb = hex_to_rgb(tok["bg"])
if bg_rgb:
draw.rectangle([x, y, x + w, y + line_h], fill=bg_rgb)
fill = hex_to_rgb(tok["color"]) if tok["color"] else None
draw_text(img, (round(x), y), tok["text"], font, fill or DEFAULT_FG)
if tok["underline"]:
underline_y = y + font.size + 1
draw.rectangle([x, underline_y, x + w, underline_y + underline_h], fill=fill or DEFAULT_FG)
x += w
if gi < len(line) - 1:
x += space_width
def _render_text(cfg: TextWidgetConfig, target_w: int, target_h: int) -> Image.Image:
bg = hex_to_rgb(cfg.background_color) or DEFAULT_BG
img = Image.new("RGB", (target_w, target_h), bg)
draw = ImageDraw.Draw(img)
max_width = max(10, target_w - 2 * MARGIN)
max_height = max(10, target_h - 2 * MARGIN)
size, lines = _fit(draw, cfg.content or [], cfg.font_size, max_width, max_height)
line_h = round(size * LINE_HEIGHT_FACTOR)
space_width = draw.textlength(" ", font=_font(False, False, size))
total_h = len(lines) * line_h
y = MARGIN + max(0, (max_height - total_h) // 2)
align = cfg.align if cfg.align in ("left", "center", "right") else "left"
for line in lines:
if y + line_h > target_h:
break # ran out of room even at the smallest size -- clip remaining lines rather than overflow
_draw_line(img, draw, line, y, size, line_h, max_width, align, space_width)
y += line_h
return img
def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: int,
is_normal_wake: bool = True) -> Image.Image:
"""is_normal_wake is unused -- see app/widgets/whiteboard.py's
identical note; every widget type's render() shares one call
signature regardless of which ones actually care."""
cfg = db.get(TextWidgetConfig, widget.id)
if cfg is None or not has_text(cfg.content):
return placeholder_image(target_w, target_h, ["Text widget", "not configured yet"])
return _render_text(cfg, target_w, target_h)
def render_preview_png(cfg: TextWidgetConfig, orientation: str, palette_rgb: list | None) -> bytes:
"""A normal browser-viewable PNG at full logical panel size --
mirrors calendar_render.render_tasks_preview_png's relationship to
render_tasks (the dialog's own preview endpoint always renders at
the frame's full size, not the widget's actual grid box, same
convention every other widget type's preview endpoint follows)."""
import io
target_w, target_h = logical_render_size(orientation)
img = _render_text(cfg, target_w, target_h)
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
buf = io.BytesIO()
quantized.convert("RGB").save(buf, format="PNG")
return buf.getvalue()
+1
View File
@@ -76,6 +76,7 @@ def test_expected_columns_exist_on_current_schema():
assert "calendar_week_start_offset" in frame_columns assert "calendar_week_start_offset" in frame_columns
assert "name" in task_widget_columns # migration 19 assert "name" in task_widget_columns # migration 19
assert "static_widget_configs" in inspector.get_table_names() # migration 20 assert "static_widget_configs" in inspector.get_table_names() # migration 20
assert "text_widget_configs" in inspector.get_table_names() # migration 21
# --- widget system backfill (migration 16 + _ensure_widgets_backfilled) --- # --- widget system backfill (migration 16 + _ensure_widgets_backfilled) ---
+149
View File
@@ -0,0 +1,149 @@
"""app.text_content.parse_rich_text -- pure parsing logic, no HTTP, no
DB. This is the sanitization boundary for the text widget's dialog
(widget_dialog_text.js posts contenteditable innerHTML here, see
routers/api_widgets.py's api_widget_config_save "text" branch); the
main thing under test is that only recognized style flags survive and
everything else (unknown tags/attributes, unparseable colors, excess
length) is silently dropped rather than round-tripped."""
from __future__ import annotations
from app.text_content import MAX_TOTAL_CHARS, has_text, parse_rich_text
def test_plain_text_is_one_paragraph_one_run():
paragraphs = parse_rich_text("Hello world")
assert paragraphs == [[{"text": "Hello world", "bold": False, "italic": False,
"underline": False, "color": None, "bg": None}]]
def test_bold_italic_underline_tags():
paragraphs = parse_rich_text("<b>bold</b> <i>italic</i> <u>under</u>")
runs = paragraphs[0]
assert runs[0]["bold"] is True and runs[0]["text"] == "bold"
assert runs[2]["italic"] is True and runs[2]["text"] == "italic"
assert runs[4]["underline"] is True and runs[4]["text"] == "under"
def test_strong_and_em_are_treated_like_b_and_i():
paragraphs = parse_rich_text("<strong>bold</strong><em>italic</em>")
runs = paragraphs[0]
assert runs[0]["bold"] is True
assert runs[1]["italic"] is True
def test_span_style_color_and_background():
html = '<span style="color: rgb(207, 0, 15); background-color: #ffdb00;">hi</span>'
run = parse_rich_text(html)[0][0]
assert run["color"] == "#cf000f"
assert run["bg"] == "#ffdb00"
def test_span_style_font_weight_and_style_and_decoration():
html = '<span style="font-weight: bold; font-style: italic; text-decoration: underline;">x</span>'
run = parse_rich_text(html)[0][0]
assert run["bold"] is True
assert run["italic"] is True
assert run["underline"] is True
def test_font_tag_color_attribute():
run = parse_rich_text('<font color="#00ff00">green</font>')[0][0]
assert run["color"] == "#00ff00"
def test_short_hex_color_expands():
run = parse_rich_text('<span style="color: #f00;">red</span>')[0][0]
assert run["color"] == "#ff0000"
def test_unparseable_color_is_dropped():
run = parse_rich_text('<span style="color: papayawhip;">x</span>')[0][0]
assert run["color"] is None
def test_nested_styles_combine():
run = parse_rich_text("<b><i>both</i></b>")[0][0]
assert run["bold"] is True
assert run["italic"] is True
def test_style_does_not_leak_past_closing_tag():
paragraphs = parse_rich_text("<b>bold</b>plain")
runs = paragraphs[0]
assert runs[0]["bold"] is True
assert runs[1]["bold"] is False
def test_div_per_line_becomes_separate_paragraphs():
paragraphs = parse_rich_text("<div>line one</div><div>line two</div>")
assert [p[0]["text"] for p in paragraphs] == ["line one", "line two"]
def test_shift_enter_br_within_a_div_also_breaks_paragraphs():
paragraphs = parse_rich_text("<div>line one<br>line two</div>")
assert [p[0]["text"] for p in paragraphs] == ["line one", "line two"]
def test_blank_line_idiom_produces_one_empty_paragraph():
html = "<div>A</div><div><br></div><div>B</div>"
paragraphs = parse_rich_text(html)
assert [p[0]["text"] if p else None for p in paragraphs] == ["A", None, "B"]
def test_double_blank_line_produces_two_empty_paragraphs():
html = "<div>A</div><div><br></div><div><br></div><div>B</div>"
paragraphs = parse_rich_text(html)
assert [p[0]["text"] if p else None for p in paragraphs] == ["A", None, None, "B"]
def test_mid_word_style_change_does_not_insert_a_space():
""""wor" bolded, "ld" not -- must still read as one word "world" when
rendered (see app/widgets/text.py's word-grouping), not "wor ld"."""
paragraphs = parse_rich_text("<b>wor</b>ld")
runs = paragraphs[0]
assert [r["text"] for r in runs] == ["wor", "ld"]
def test_unrecognized_tags_are_dropped_but_their_text_survives_as_plain():
"""A <script> (or any tag outside the recognized set) never executes
or persists as a tag -- its text content just becomes an ordinary
unstyled run, exactly like any other stray text."""
paragraphs = parse_rich_text('<script>alert(1)</script>hello')
runs = paragraphs[0]
assert any(r["text"] == "alert(1)" and not r["bold"] for r in runs)
assert any(r["text"] == "hello" for r in runs)
def test_style_attribute_cannot_smuggle_unrecognized_css():
"""Only color/background-color/font-weight/font-style/text-decoration
are ever read from a style attribute -- anything else (a CSS
injection attempt via e.g. a bogus property) is just ignored."""
html = '<span style="position: fixed; top: 0; color: #123456;">x</span>'
run = parse_rich_text(html)[0][0]
assert run["color"] == "#123456"
# No other keys were introduced by the extra property.
assert set(run.keys()) == {"text", "bold", "italic", "underline", "color", "bg"}
def test_content_is_truncated_to_max_total_chars():
html = "a" * (MAX_TOTAL_CHARS + 500)
paragraphs = parse_rich_text(html)
total = sum(len(r["text"]) for p in paragraphs for r in p)
assert total == MAX_TOTAL_CHARS
def test_malformed_html_does_not_raise():
parse_rich_text("<b><i>unclosed tags <div>and a stray </b>")
def test_has_text_false_for_none_and_blank():
assert has_text(None) is False
assert has_text([]) is False
assert has_text([[]]) is False
assert has_text([[{"text": " ", "bold": False, "italic": False,
"underline": False, "color": None, "bg": None}]]) is False
def test_has_text_true_for_real_content():
assert has_text(parse_rich_text("hi")) is True
@@ -19,6 +19,7 @@ from app.models import (
PhotoWidgetConfig, PhotoWidgetConfig,
StaticWidgetConfig, StaticWidgetConfig,
TaskWidgetConfig, TaskWidgetConfig,
TextWidgetConfig,
Widget, Widget,
) )
@@ -67,6 +68,18 @@ def _add_static_widget(db_session) -> Widget:
return widget return widget
def _add_text_widget(db_session) -> Widget:
import time
widget = Widget(frame_id=1, widget_type="text", x=0, y=0, w=2, h=1,
sort_order=1, created_at=time.time())
db_session.add(widget)
db_session.flush()
db_session.add(TextWidgetConfig(widget_id=widget.id))
db_session.commit()
return widget
def _png_bytes(size=(20, 10), color=(10, 20, 30)) -> bytes: def _png_bytes(size=(20, 10), color=(10, 20, 30)) -> bytes:
buf = io.BytesIO() buf = io.BytesIO()
Image.new("RGB", size, color).save(buf, format="PNG") Image.new("RGB", size, color).save(buf, format="PNG")
@@ -184,6 +197,49 @@ def test_config_save_rejects_an_unrecognized_static_display_mode(client, db_sess
assert cfg.display_mode == "crop_fill" assert cfg.display_mode == "crop_fill"
def test_config_save_updates_a_text_widget(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_text_widget(db_session)
resp = client.post(
f"/api/frames/1/widgets/{widget.id}/config",
data={
"text_html": '<div>Hello <b>world</b></div>',
"text_font_size": "40",
"text_align": "center",
"text_background_color": "#ffdb00",
},
headers=csrf_headers(client),
)
assert resp.status_code == 200, resp.text
cfg = db_session.get(TextWidgetConfig, widget.id)
assert cfg.content == [[
{"text": "Hello ", "bold": False, "italic": False, "underline": False, "color": None, "bg": None},
{"text": "world", "bold": True, "italic": False, "underline": False, "color": None, "bg": None},
]]
assert cfg.font_size == 40
assert cfg.align == "center"
assert cfg.background_color == "#ffdb00"
def test_config_save_clamps_text_font_size_and_rejects_bad_align_and_color(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_text_widget(db_session)
resp = client.post(
f"/api/frames/1/widgets/{widget.id}/config",
data={"text_font_size": "500", "text_align": "diagonal", "text_background_color": "not-a-color"},
headers=csrf_headers(client),
)
assert resp.status_code == 200, resp.text
cfg = db_session.get(TextWidgetConfig, widget.id)
assert cfg.font_size == 96 # clamped to MAX_TEXT_FONT_SIZE
assert cfg.align == "left" # fell back to the default
assert cfg.background_color == "#ffffff" # fell back to the default
def test_config_save_only_partially_updates_provided_fields(client, db_session): def test_config_save_only_partially_updates_provided_fields(client, db_session):
"""Fields not present in the POST are left untouched -- the whole """Fields not present in the POST are left untouched -- the whole
point of the partial-update convention (each dialog's own form only point of the partial-update convention (each dialog's own form only
@@ -330,3 +386,33 @@ def test_preview_static_renders_after_upload(client, db_session):
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/static") resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/static")
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
assert resp.headers["content-type"] == "image/png" assert resp.headers["content-type"] == "image/png"
# --- text: preview -----------------------------------------------------
def test_preview_text_400s_before_anything_is_authored(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_text_widget(db_session)
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/text")
assert resp.status_code == 400
def test_preview_text_renders_after_saving_content(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_text_widget(db_session)
client.post(
f"/api/frames/1/widgets/{widget.id}/config",
data={"text_html": "<div>Hello world</div>"},
headers=csrf_headers(client),
)
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/text")
assert resp.status_code == 200, resp.text
assert resp.headers["content-type"] == "image/png"
def test_preview_text_400s_for_a_widget_that_is_not_text(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_static_widget(db_session)
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/text")
assert resp.status_code == 400
+106
View File
@@ -0,0 +1,106 @@
"""app.widgets.text -- unit-level, no HTTP: constructs Widget/
TextWidgetConfig rows directly with already-parsed run structures (the
HTML-parsing step is covered separately in test_text_content.py; the
HTTP-level config-save/preview endpoints in
test_widget_config_and_queue_endpoints.py). These tests only exercise
render()'s own word-wrap/shrink-to-fit/style layout."""
from __future__ import annotations
import time
from app import widgets
from app.models import Frame, TextWidgetConfig, Widget
def _run(text, **overrides) -> dict:
run = {"text": text, "bold": False, "italic": False, "underline": False, "color": None, "bg": None}
run.update(overrides)
return run
def _make_widget(db_session, **cfg_kwargs) -> tuple[Frame, Widget]:
frame = db_session.get(Frame, 1)
widget = Widget(frame_id=frame.id, widget_type="text", x=0, y=0, w=2, h=1,
sort_order=0, created_at=time.time())
db_session.add(widget)
db_session.flush()
db_session.add(TextWidgetConfig(widget_id=widget.id, **cfg_kwargs))
db_session.commit()
return frame, widget
def test_render_shows_a_placeholder_when_never_configured(db_session):
frame, widget = _make_widget(db_session)
img = widgets.text.render(db_session, frame, widget, 300, 200)
assert img.size == (300, 200)
assert img.mode == "RGB"
def test_render_shows_a_placeholder_for_whitespace_only_content(db_session):
frame, widget = _make_widget(db_session, content=[[_run(" ")]])
img = widgets.text.render(db_session, frame, widget, 300, 200)
assert img.size == (300, 200)
def test_render_draws_configured_text(db_session):
frame, widget = _make_widget(db_session, content=[[_run("Hello world")]])
img = widgets.text.render(db_session, frame, widget, 300, 200)
assert img.size == (300, 200)
assert img.mode == "RGB"
# Not just a blank/placeholder canvas -- some non-background pixel exists.
assert img.getcolors(maxcolors=1) is None or img.getcolors()[0][0] != 300 * 200
def test_render_respects_background_color(db_session):
frame, widget = _make_widget(db_session, content=[[_run("hi")]], background_color="#ff0000")
img = widgets.text.render(db_session, frame, widget, 50, 40)
assert img.getpixel((0, 0)) == (255, 0, 0)
def test_render_shrinks_font_to_fit_a_tiny_box(db_session):
long_text = " ".join(["word"] * 40)
frame, widget = _make_widget(db_session, content=[[_run(long_text)]], font_size=96)
# grid.MIN_FOOTPRINT["text"] is (2, 1) cells -- on an 8x5 grid against
# a full 800x480 panel that's a 200x96 box, the smallest a text
# widget can actually be placed at.
img = widgets.text.render(db_session, frame, widget, 200, 96)
assert img.size == (200, 96)
def test_render_wraps_across_multiple_paragraphs(db_session):
content = [[_run("First paragraph with several words to wrap.")],
[_run("Second paragraph, also with text.")]]
frame, widget = _make_widget(db_session, content=content)
img = widgets.text.render(db_session, frame, widget, 250, 150)
assert img.size == (250, 150)
def test_render_applies_bold_italic_underline_color_and_highlight(db_session):
content = [[
_run("bold", bold=True),
_run(" "),
_run("italic", italic=True),
_run(" "),
_run("underline", underline=True),
_run(" "),
_run("colored", color="#cf000f"),
_run(" "),
_run("highlighted", bg="#ffdb00"),
]]
frame, widget = _make_widget(db_session, content=content)
img = widgets.text.render(db_session, frame, widget, 400, 150)
assert img.size == (400, 150)
def test_render_respects_alignment(db_session):
for align in ("left", "center", "right"):
frame, widget = _make_widget(db_session, content=[[_run("hi")]], align=align)
img = widgets.text.render(db_session, frame, widget, 200, 100)
assert img.size == (200, 100)
def test_no_button_actions():
"""Fixed authored text -- nothing to advance/back/check."""
assert widgets.text.ACTIONS == {}
assert widgets.text.ACTION_LABELS == {}