diff --git a/docs/widgets.md b/docs/widgets.md index 7c2813d..604e5df 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -1,8 +1,8 @@ # Widget system 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 -icons on an Android home screen. A frame can hold several widgets of the +placed/sized widgets (photos/calendar/whiteboard/tasks/static image/text), like +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 by side). This replaced an earlier design where `Frame.mode` picked exactly one @@ -20,16 +20,20 @@ a button press does. ## Data model - `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 `routers/api_widgets.py`, re-validated regardless of what the client already checked) -- that's what keeps compositing simple: no z-order, no blending, just N independent regions pasted onto one shared canvas. - Per-type 1:1 extension tables -- `PhotoWidgetConfig`, `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 - 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 advance/back/queue logic ports across widget instances unchanged. `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`): 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 (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 @@ -71,7 +75,7 @@ alone. `app/widgets/` is the render/action registry -- one module per `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`: 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. - `ACTIONS: dict[str, Callable]` -- named button actions this type supports (`"advance"`/`"back"` for photos and calendar, `"check_now"` - for whiteboard). Empty for tasks and static image -- nothing to - advance/back/force for a passive checklist or a fixed uploaded image. + for whiteboard). Empty for tasks, static image, and text -- nothing to + 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- assignment UI. diff --git a/server/app/fonts/NotoSans-Bold.ttf b/server/app/fonts/NotoSans-Bold.ttf new file mode 100644 index 0000000..aae7546 Binary files /dev/null and b/server/app/fonts/NotoSans-Bold.ttf differ diff --git a/server/app/fonts/NotoSans-BoldItalic.ttf b/server/app/fonts/NotoSans-BoldItalic.ttf new file mode 100644 index 0000000..6f685b2 Binary files /dev/null and b/server/app/fonts/NotoSans-BoldItalic.ttf differ diff --git a/server/app/fonts/NotoSans-Italic.ttf b/server/app/fonts/NotoSans-Italic.ttf new file mode 100644 index 0000000..7f53133 Binary files /dev/null and b/server/app/fonts/NotoSans-Italic.ttf differ diff --git a/server/app/fonts/NotoSans-Regular.ttf b/server/app/fonts/NotoSans-Regular.ttf new file mode 100644 index 0000000..f27f4ff Binary files /dev/null and b/server/app/fonts/NotoSans-Regular.ttf differ diff --git a/server/app/grid.py b/server/app/grid.py index 0b3bf01..193527c 100644 --- a/server/app/grid.py +++ b/server/app/grid.py @@ -31,6 +31,7 @@ MIN_FOOTPRINT: dict[str, tuple[int, int]] = { "whiteboard": (2, 2), "tasks": (2, 2), "static": (1, 1), + "text": (2, 1), } Rect = tuple[int, int, int, int] # (x, y, w, h) diff --git a/server/app/migration.py b/server/app/migration.py index ba95b6b..b568dbc 100644 --- a/server/app/migration.py +++ b/server/app/migration.py @@ -511,6 +511,14 @@ def _migration_20(conn) -> None: 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 = [ (1, _migration_1), (2, _migration_2), @@ -532,6 +540,7 @@ MIGRATIONS = [ (18, _migration_18), (19, _migration_19), (20, _migration_20), + (21, _migration_21), ] diff --git a/server/app/models.py b/server/app/models.py index d10fb97..a4d35da 100644 --- a/server/app/models.py +++ b/server/app/models.py @@ -553,6 +553,37 @@ class WhiteboardWidgetConfig(Base): 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): """One static-image widget's uploaded content + display settings -- unlike every other widget type, this one has no live upstream to @@ -583,6 +614,7 @@ WIDGET_CONFIG_MODELS: dict[str, type] = { "whiteboard": WhiteboardWidgetConfig, "tasks": TaskWidgetConfig, "static": StaticWidgetConfig, + "text": TextWidgetConfig, } diff --git a/server/app/routers/api_widgets.py b/server/app/routers/api_widgets.py index 2277f1b..48fcadc 100644 --- a/server/app/routers/api_widgets.py +++ b/server/app/routers/api_widgets.py @@ -33,6 +33,7 @@ from ..image_pipeline import ( DEFAULT_DISPLAY_MODE, DEFAULT_STATIC_DISPLAY_MODE, DISPLAY_MODES, + hex_to_rgb, STATIC_DISPLAY_MODES, render_preview_png, ) @@ -45,11 +46,14 @@ from ..models import ( PhotoWidgetConfig, StaticWidgetConfig, TaskWidgetConfig, + TextWidgetConfig, WhiteboardWidgetConfig, WIDGET_CONFIG_MODELS, Widget, ) +from ..text_content import has_text, parse_rich_text from ..widgets import WIDGET_TYPES +from ..widgets import text as text_widget from .common import ( calendar_sources_for_widget, fetch_source_and_faces, @@ -69,6 +73,8 @@ router = APIRouter() MIN_QUEUE_TARGET_LEN = 5 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 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_name: str | 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 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): if display_mode is not None: 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: cfg.stats_config_saves += 1 return {"status": "saved"} @@ -852,6 +878,25 @@ def api_widget_preview_static( 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 ------------------------------------------ class WhiteboardSourceRequest(BaseModel): diff --git a/server/app/routers/frame_pages.py b/server/app/routers/frame_pages.py index de6658e..18798be 100644 --- a/server/app/routers/frame_pages.py +++ b/server/app/routers/frame_pages.py @@ -35,6 +35,7 @@ from ..models import ( PhotoWidgetConfig, StaticWidgetConfig, TaskWidgetConfig, + TextWidgetConfig, User, UserFrame, 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}, }) + 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": whiteboard_cfg = db.get(WhiteboardWidgetConfig, widget.id) viewer_has_webdav_creds = bool( diff --git a/server/app/static/common.js b/server/app/static/common.js index 9f5403b..c60225f 100644 --- a/server/app/static/common.js +++ b/server/app/static/common.js @@ -62,7 +62,7 @@ // UI (Layout canvas, Add-a-widget buttons, button-assignment dropdowns). const WIDGET_LABELS = { photos: 'Photos', calendar: 'Calendar', whiteboard: 'Whiteboard (alpha)', tasks: 'Tasks', - static: 'Static image', + static: 'Static image', text: 'Text', }; function showStatus(ok, message) { diff --git a/server/app/static/frame_layout.js b/server/app/static/frame_layout.js index 02cc907..8b67911 100644 --- a/server/app/static/frame_layout.js +++ b/server/app/static/frame_layout.js @@ -286,11 +286,11 @@ window.addEventListener('resize', () => { // icon was clicked). const DIALOG_INIT = { photos: initPhotosDialog, calendar: initCalendarDialog, whiteboard: initWhiteboardDialog, - tasks: initTasksDialog, static: initStaticDialog, + tasks: initTasksDialog, static: initStaticDialog, text: initTextDialog, }; const DIALOG_CLOSE = { photos: closePhotosDialog, calendar: closeCalendarDialog, whiteboard: closeWhiteboardDialog, - tasks: closeTasksDialog, static: closeStaticDialog, + tasks: closeTasksDialog, static: closeStaticDialog, text: closeTextDialog, }; let openDialogWidgetType = null; diff --git a/server/app/static/theme.css b/server/app/static/theme.css index 3c3a24e..08b66d2 100644 --- a/server/app/static/theme.css +++ b/server/app/static/theme.css @@ -205,6 +205,12 @@ details.card .sub { margin-top: 8px; } color: var(--text-muted); font-variant-numeric: tabular-nums; } +input[type="color"] { + width: 44px; + height: 34px; + padding: 2px; + cursor: pointer; +} input[type="range"] { width: 100%; margin-top: 8px; @@ -281,6 +287,49 @@ input:focus, select:focus { .checkbox-row input { width: auto; margin-top: 0; } .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 > li { margin-top: 14px; } .calendar-user-list > li:first-child { margin-top: 0; } diff --git a/server/app/static/widget_dialog_text.js b/server/app/static/widget_dialog_text.js new file mode 100644 index 0000000..f10c7bc --- /dev/null +++ b/server/app/static/widget_dialog_text.js @@ -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 , 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
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 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; +} diff --git a/server/app/templates/_widget_dialog_text.html b/server/app/templates/_widget_dialog_text.html new file mode 100644 index 0000000..58573c5 --- /dev/null +++ b/server/app/templates/_widget_dialog_text.html @@ -0,0 +1,48 @@ +

Text widget

+ +
+

Text

+

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.

+
+ + + + + + + +
+
+
+ +
+

Settings

+
+ + + + +
+
+ +
+

Preview

+

How this widget currently renders.

+ Text widget preview + +
diff --git a/server/app/templates/frame_layout.html b/server/app/templates/frame_layout.html index 5ee80bc..08bf0bd 100644 --- a/server/app/templates/frame_layout.html +++ b/server/app/templates/frame_layout.html @@ -60,5 +60,6 @@ + {% endblock %} diff --git a/server/app/text_content.py b/server/app/text_content.py new file mode 100644 index 0000000..3b49dba --- /dev/null +++ b/server/app/text_content.py @@ -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
even without a deliberate + # blank line) down to one paragraph break per actual line gap, + # while still letting an explicit
when already at a fresh + # line start (Chrome's "

" 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) diff --git a/server/app/widgets/__init__.py b/server/app/widgets/__init__.py index 6606442..0b7c95f 100644 --- a/server/app/widgets/__init__.py +++ b/server/app/widgets/__init__.py @@ -36,7 +36,7 @@ Each module in this package exposes: from __future__ import annotations -from . import calendar, photos, static_image, tasks, whiteboard +from . import calendar, photos, static_image, tasks, text, whiteboard WIDGET_TYPES = { "photos": photos, @@ -44,4 +44,5 @@ WIDGET_TYPES = { "whiteboard": whiteboard, "tasks": tasks, "static": static_image, + "text": text, } diff --git a/server/app/widgets/text.py b/server/app/widgets/text.py new file mode 100644 index 0000000..228cc03 --- /dev/null +++ b/server/app/widgets/text.py @@ -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() diff --git a/server/tests/test_migrations.py b/server/tests/test_migrations.py index 41c7b9a..0d05179 100644 --- a/server/tests/test_migrations.py +++ b/server/tests/test_migrations.py @@ -76,6 +76,7 @@ def test_expected_columns_exist_on_current_schema(): assert "calendar_week_start_offset" in frame_columns assert "name" in task_widget_columns # migration 19 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) --- diff --git a/server/tests/test_text_content.py b/server/tests/test_text_content.py new file mode 100644 index 0000000..f14633f --- /dev/null +++ b/server/tests/test_text_content.py @@ -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("bold italic under") + 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("bolditalic") + runs = paragraphs[0] + assert runs[0]["bold"] is True + assert runs[1]["italic"] is True + + +def test_span_style_color_and_background(): + html = 'hi' + 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 = 'x' + 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('green')[0][0] + assert run["color"] == "#00ff00" + + +def test_short_hex_color_expands(): + run = parse_rich_text('red')[0][0] + assert run["color"] == "#ff0000" + + +def test_unparseable_color_is_dropped(): + run = parse_rich_text('x')[0][0] + assert run["color"] is None + + +def test_nested_styles_combine(): + run = parse_rich_text("both")[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("boldplain") + 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("
line one
line two
") + 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("
line one
line two
") + assert [p[0]["text"] for p in paragraphs] == ["line one", "line two"] + + +def test_blank_line_idiom_produces_one_empty_paragraph(): + html = "
A

B
" + 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 = "
A


B
" + 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("world") + 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 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 = 'x' + 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("unclosed tags
and a stray ") + + +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 diff --git a/server/tests/test_widget_config_and_queue_endpoints.py b/server/tests/test_widget_config_and_queue_endpoints.py index 380d9ed..69b1903 100644 --- a/server/tests/test_widget_config_and_queue_endpoints.py +++ b/server/tests/test_widget_config_and_queue_endpoints.py @@ -19,6 +19,7 @@ from app.models import ( PhotoWidgetConfig, StaticWidgetConfig, TaskWidgetConfig, + TextWidgetConfig, Widget, ) @@ -67,6 +68,18 @@ def _add_static_widget(db_session) -> 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: buf = io.BytesIO() 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" +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": '
Hello world
', + "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): """Fields not present in the POST are left untouched -- the whole 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") assert resp.status_code == 200, resp.text 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": "
Hello world
"}, + 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 diff --git a/server/tests/test_widgets_text.py b/server/tests/test_widgets_text.py new file mode 100644 index 0000000..6b12d1e --- /dev/null +++ b/server/tests/test_widgets_text.py @@ -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 == {}