From b15747a6048921e0439a6434b792770d6a6106c1 Mon Sep 17 00:00:00 2001 From: Thomas Faour Date: Mon, 27 Jul 2026 19:51:04 +0000 Subject: [PATCH] Add per-widget border option (style, thickness, palette color) A Widget-level property (border_style/border_thickness/border_color_index), not a per-type config field, since every widget type can have one -- drawn once centrally in device.py's _render_widgets before compositing, using an exact panel palette color so it never dithers. Styles: solid, dashed, dotted, and a fancy double-line picture-frame-mat look. Configurable from a shared "Border" card in every widget's gear-icon dialog. --- CLAUDE.md | 1 - docs/widgets.md | 16 ++ server/app/image_pipeline.py | 108 +++++++++++++ server/app/migration.py | 33 +++- server/app/models.py | 14 ++ server/app/routers/api_widgets.py | 39 ++++- server/app/routers/device.py | 19 ++- server/app/routers/frame_pages.py | 35 ++-- server/app/static/theme.css | 1 + server/app/static/widget_dialog_battery.js | 1 + server/app/static/widget_dialog_border.js | 48 ++++++ server/app/static/widget_dialog_calendar.js | 1 + server/app/static/widget_dialog_photos.js | 1 + server/app/static/widget_dialog_static.js | 1 + server/app/static/widget_dialog_tasks.js | 1 + server/app/static/widget_dialog_text.js | 1 + server/app/static/widget_dialog_weather.js | 1 + server/app/static/widget_dialog_whiteboard.js | 1 + .../app/templates/_widget_border_fields.html | 32 ++++ .../app/templates/_widget_dialog_battery.html | 2 + .../templates/_widget_dialog_calendar.html | 2 + .../app/templates/_widget_dialog_photos.html | 2 + .../app/templates/_widget_dialog_static.html | 2 + .../app/templates/_widget_dialog_tasks.html | 2 + server/app/templates/_widget_dialog_text.html | 2 + .../app/templates/_widget_dialog_weather.html | 2 + .../templates/_widget_dialog_whiteboard.html | 2 + server/app/templates/frame_layout.html | 1 + server/tests/test_migrations.py | 2 + server/tests/test_widget_border.py | 149 ++++++++++++++++++ 30 files changed, 503 insertions(+), 19 deletions(-) create mode 100644 server/app/static/widget_dialog_border.js create mode 100644 server/app/templates/_widget_border_fields.html create mode 100644 server/tests/test_widget_border.py diff --git a/CLAUDE.md b/CLAUDE.md index 23f3239..6200c32 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,6 @@ photos/calendar/whiteboard/weather widget system to the device. CURRENT TODO -add more actions for buttons (i.e. change widget/layout) -Fix spurious button assignment stuff (probably but buttons on widget config with sane defaults) --widget border option -battery life widget -sharing layouts with linked users -a "coming up this week" widget diff --git a/docs/widgets.md b/docs/widgets.md index 285d198..cad7c96 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -26,6 +26,22 @@ a button press does. `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. + Also carries an optional per-widget border (`border_style` -- `"none"` + | `"solid"` | `"dashed"` | `"dotted"` | `"fancy"`, `border_thickness`, + `border_color_index`, an index into the frame's palette so a border + always renders as one of the panel's exact 6 ink colors) directly on + `Widget` itself rather than a per-type config table, since every + widget type can have one regardless of `widget_type`. Drawn by + `image_pipeline.draw_widget_border` onto each widget's own region in + `routers/device.py`'s `_render_widgets`, before that region is pasted + onto the shared canvas -- one central integration point instead of + every `app/widgets/*.py` module needing to know about it. Set via the + gear-icon dialog's shared "Border" card (`_widget_border_fields.html`, + included by every `_widget_dialog_*.html` template) and + `POST .../widgets/{id}/border`, its own endpoint (not folded into + `api_widget_config_save`) since that endpoint's per-type dispatch is + keyed on a config row via `widget_locked`, and border fields live on + `Widget` itself, not any per-type config table. - Per-type 1:1 extension tables -- `PhotoWidgetConfig`, `CalendarWidgetConfig`, `WhiteboardWidgetConfig`, `TaskWidgetConfig`, `StaticWidgetConfig`, `TextWidgetConfig`, `WeatherWidgetConfig`, diff --git a/server/app/image_pipeline.py b/server/app/image_pipeline.py index 8624146..219ae84 100644 --- a/server/app/image_pipeline.py +++ b/server/app/image_pipeline.py @@ -3,6 +3,7 @@ from __future__ import annotations import io +import math from PIL import Image, ImageDraw, ImageEnhance, ImageFont, ImageOps @@ -32,6 +33,83 @@ def draw_text(img: Image.Image, xy: tuple[int, int], text: str, font: ImageFont. mask = mask.point(lambda p: 255 if p > _TEXT_MASK_THRESHOLD else 0) img.paste(fill, (xy[0] + bbox[0], xy[1] + bbox[1]), mask) + +def _dashed_edge(draw: ImageDraw.ImageDraw, x0: float, y0: float, x1: float, y1: float, + width: int, color: tuple[int, int, int], dash: float, gap: float) -> None: + length = math.hypot(x1 - x0, y1 - y0) + if length <= 0: + return + ux, uy = (x1 - x0) / length, (y1 - y0) / length + pos = 0.0 + while pos < length: + end = min(pos + dash, length) + draw.line([(x0 + ux * pos, y0 + uy * pos), (x0 + ux * end, y0 + uy * end)], fill=color, width=width) + pos += dash + gap + + +def _dotted_edge(draw: ImageDraw.ImageDraw, x0: float, y0: float, x1: float, y1: float, + width: int, color: tuple[int, int, int], spacing: float) -> None: + length = math.hypot(x1 - x0, y1 - y0) + if length <= 0: + return + ux, uy = (x1 - x0) / length, (y1 - y0) / length + r = max(1, width / 2) + pos = 0.0 + while pos <= length: + cx, cy = x0 + ux * pos, y0 + uy * pos + draw.ellipse([cx - r, cy - r, cx + r, cy + r], fill=color) + pos += spacing + + +def draw_widget_border(img: Image.Image, style: str, thickness: int, color: tuple[int, int, int]) -> None: + """Draws a border inset within img's own bounds, mutating it in + place -- called once per widget's own region (routers/device.py's + _render_widgets, and each widget type's own dialog preview) before + that region's image is pasted onto the shared canvas, so a border + never straddles the boundary between two adjacent widgets. `color` + should already be an exact palette RGB (see resolve_border_color) so + the stroke quantizes with zero dithering error, same reasoning as + the weather/battery icons' exact-panel-ink-RGB fills. + + "solid"/"dashed"/"dotted" are a single thickness-px stroke traced + just inside the image's edge; "fancy" is two thinner concentric + strokes with a gap between them, picture-frame-mat style. "none" (or + a non-positive thickness) draws nothing.""" + if style == "none" or thickness <= 0: + return + w, h = img.size + t = max(1, min(int(thickness), min(w, h) // 2)) + draw = ImageDraw.Draw(img) + + if style == "fancy": + line_t = max(1, t // 3) + gap = max(2, t - 2 * line_t) + draw.rectangle([0, 0, w - 1, h - 1], outline=color, width=line_t) + inset = line_t + gap + if w - 2 * inset > 1 and h - 2 * inset > 1: + draw.rectangle([inset, inset, w - 1 - inset, h - 1 - inset], outline=color, width=line_t) + return + + if style == "solid": + draw.rectangle([0, 0, w - 1, h - 1], outline=color, width=t) + return + + # dashed/dotted trace the same centered-on-the-edge path solid/ + # fancy's rectangle outline draws, so all four styles sit at the + # same inset regardless of which is chosen. + half = t / 2 + x0, y0, x1, y1 = half, half, w - 1 - half, h - 1 - half + edges = [(x0, y0, x1, y0), (x1, y0, x1, y1), (x1, y1, x0, y1), (x0, y1, x0, y0)] + if style == "dashed": + dash, gap = t * 3, t * 2 + for ex0, ey0, ex1, ey1 in edges: + _dashed_edge(draw, ex0, ey0, ex1, ey1, t, color, dash, gap) + elif style == "dotted": + spacing = max(t * 2, t + 4) + for ex0, ey0, ex1, ey1 in edges: + _dotted_edge(draw, ex0, ey0, ex1, ey1, t, color, spacing) + + # How each orientation maps the logically-composed image onto the native # 800x480 panel. "portrait"/"portrait_flipped" compose at 480x800 (so the # crop ratio matches how the frame actually hangs) and rotate into native @@ -94,6 +172,21 @@ PALETTE_LABELS = ["Black", "White", "Yellow", "Red", "Blue", "Green"] # upstream. PANEL_CODES = [0x0, 0x1, 0x2, 0x3, 0x5, 0x6] +# Per-widget optional border (models.Widget.border_style, see +# draw_widget_border below). "none" is the default/no-op; the rest are +# thickness-px strokes inset within the widget's own region. +BORDER_STYLES = ["none", "solid", "dashed", "dotted", "fancy"] +BORDER_STYLE_LABELS = { + "none": "None", + "solid": "Solid", + "dashed": "Dashed", + "dotted": "Dotted", + "fancy": "Fancy (double line)", +} +MIN_BORDER_THICKNESS = 1 +MAX_BORDER_THICKNESS = 8 +DEFAULT_BORDER_THICKNESS = 3 + def palette_to_hex(palette_rgb: list) -> list[str]: """[(0,0,0), ...] -> ["#000000", ...], for pre-filling the Advanced @@ -101,6 +194,21 @@ def palette_to_hex(palette_rgb: list) -> list[str]: return ["#%02x%02x%02x" % tuple(c) for c in palette_rgb] +def resolve_border_color(color_index: int, palette_rgb: list | None) -> tuple[int, int, int]: + """Widget.border_color_index -> an actual RGB tuple, against this + frame's tuned palette if it has one (falls back to + DEFAULT_PALETTE_RGB) -- so a border always renders as one of the + panel's real 6 ink colors and never needs to be dithered, same + reasoning as the weather/battery icons' exact-panel-ink-RGB fills + (see docs/widgets.md). Out-of-range indexes (a stale value from a + frame that used to have more colors, though that never happens + today) fall back to Black rather than raising.""" + palette = palette_rgb or DEFAULT_PALETTE_RGB + if 0 <= color_index < len(palette): + return tuple(palette[color_index]) + return tuple(palette[0]) + + def hex_to_rgb(hex_str: str) -> tuple[int, int, int] | None: """"#1a2b3c" -> (26, 43, 60), or None for anything that isn't exactly a 6-hex-digit color (what always sends, but a diff --git a/server/app/migration.py b/server/app/migration.py index eecb004..15a6c18 100644 --- a/server/app/migration.py +++ b/server/app/migration.py @@ -385,8 +385,9 @@ def _migration_17(conn) -> None: "SELECT COALESCE(MAX(sort_order), 0) FROM widgets WHERE frame_id = :frame_id" ), {"frame_id": row["frame_id"]}).scalar() result = conn.execute(text( - "INSERT INTO widgets (frame_id, widget_type, x, y, w, h, sort_order, created_at) " - "VALUES (:frame_id, 'tasks', :x, :y, :w, :h, :sort_order, :created_at)" + "INSERT INTO widgets (frame_id, widget_type, x, y, w, h, sort_order, created_at, " + "border_style, border_thickness, border_color_index) " + "VALUES (:frame_id, 'tasks', :x, :y, :w, :h, :sort_order, :created_at, 'none', 3, 0)" ), {"frame_id": row["frame_id"], "x": x, "y": y, "w": w, "h": h, "sort_order": max_sort + 1, "created_at": now}) new_widget_id = result.lastrowid @@ -677,6 +678,33 @@ def _migration_25(conn) -> None: )) +def _migration_26(conn) -> None: + """Per-widget border (see models.Widget.border_style/border_thickness/ + border_color_index, image_pipeline.draw_widget_border) -- a shared + property on the widgets table itself, not a per-type config table, + since every widget type can have one regardless of widget_type. + border_style defaults to 'none' so existing widgets keep rendering + exactly as before until someone opts in via a widget's dialog. + + Guarded per-column (unlike every earlier ALTER TABLE ADD COLUMN + migration in this file) because widgets is the one table + test_migrations.py's upgrade-path tests deliberately leave un-dropped + across a simulated old-schema_version replay (see those tests' own + comments: it hasn't changed shape since migration 16 created it, so + reusing the fresh-install create_all() copy -- which, unlike this + ALTER, already reflects models.py's current border_* columns -- was + safe up to now). Without the guard, replaying this migration in that + scenario re-adds a column that's already there and SQLite raises + "duplicate column name".""" + existing = {c["name"] for c in inspect(conn).get_columns("widgets")} + if "border_style" not in existing: + conn.execute(text("ALTER TABLE widgets ADD COLUMN border_style TEXT NOT NULL DEFAULT 'none'")) + if "border_thickness" not in existing: + conn.execute(text("ALTER TABLE widgets ADD COLUMN border_thickness INTEGER NOT NULL DEFAULT 3")) + if "border_color_index" not in existing: + conn.execute(text("ALTER TABLE widgets ADD COLUMN border_color_index INTEGER NOT NULL DEFAULT 0")) + + MIGRATIONS = [ (1, _migration_1), (2, _migration_2), @@ -703,6 +731,7 @@ MIGRATIONS = [ (23, _migration_23), (24, _migration_24), (25, _migration_25), + (26, _migration_26), ] diff --git a/server/app/models.py b/server/app/models.py index cd6f86e..2db8ef7 100644 --- a/server/app/models.py +++ b/server/app/models.py @@ -445,6 +445,20 @@ class Widget(Base): # table needs to match a specific attribute name here. sort_order: Mapped[int] = mapped_column(Integer, default=0) created_at: Mapped[float] = mapped_column(Float, default=time.time) + # Optional decorative border, drawn once around this widget's own + # region (routers/device.py's _render_widgets) regardless of + # widget_type -- a Widget-level property, not a per-type config + # column, since every widget type can have one. See + # image_pipeline.BORDER_STYLES/draw_widget_border. "none" (the + # default) draws nothing, so existing widgets don't suddenly grow a + # border. border_color_index indexes into the frame's palette_rgb + # (0-5, Black/White/Yellow/Red/Blue/Green) rather than storing an + # arbitrary hex -- an exact palette color quantizes with zero + # dithering error, same reasoning as the weather/battery icons' + # exact-panel-ink-RGB fills (see docs/widgets.md). + border_style: Mapped[str] = mapped_column(String, default="none") + border_thickness: Mapped[int] = mapped_column(Integer, default=3) + border_color_index: Mapped[int] = mapped_column(Integer, default=0) __table_args__ = (Index("ix_widgets_frame", "frame_id"),) diff --git a/server/app/routers/api_widgets.py b/server/app/routers/api_widgets.py index bd43376..2efde8a 100644 --- a/server/app/routers/api_widgets.py +++ b/server/app/routers/api_widgets.py @@ -30,10 +30,14 @@ from .. import calendar_render, grid, photo_queue, quiet_hours, weather, weather from ..auth import require_frame_control, require_frame_view, require_user_api from ..db import frame_locked, get_db, widget_locked from ..image_pipeline import ( + BORDER_STYLES, DEFAULT_DISPLAY_MODE, DEFAULT_STATIC_DISPLAY_MODE, DISPLAY_MODES, hex_to_rgb, + MAX_BORDER_THICKNESS, + MIN_BORDER_THICKNESS, + PALETTE_LABELS, STATIC_DISPLAY_MODES, render_preview_png, ) @@ -84,7 +88,8 @@ MAX_TASKS_NAME_LEN = 40 # a sane on-panel-header length, see calendar_render._d def _widget_dict(w: Widget) -> dict: return {"id": w.id, "widget_type": w.widget_type, "x": w.x, "y": w.y, "w": w.w, "h": w.h, - "sort_order": w.sort_order} + "sort_order": w.sort_order, "border_style": w.border_style, + "border_thickness": w.border_thickness, "border_color_index": w.border_color_index} def require_widget_view( @@ -226,6 +231,38 @@ def api_widget_move( return _widget_dict(widget) +class WidgetBorderRequest(BaseModel): + border_style: str + border_thickness: int + border_color_index: int + + +@router.post("/api/frames/{frame_id}/widgets/{widget_id}/border") +def api_widget_border( + body: WidgetBorderRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control), + db: Session = Depends(get_db), +): + """Sets this widget's optional border -- a shared Widget-level + property (see models.Widget), not a per-type config field, since + every widget type can have one regardless of widget_type. Its own + endpoint (not folded into api_widget_config_save) for the same + reason: that endpoint's per-type dispatch is keyed on a config row + via widget_locked, and border fields live on Widget itself, not any + per-type config table.""" + frame, widget = frame_widget + if body.border_style not in BORDER_STYLES: + raise HTTPException(400, f"border_style must be one of {BORDER_STYLES}") + if not (0 <= body.border_color_index < len(PALETTE_LABELS)): + raise HTTPException(400, "border_color_index must be 0-5 (a panel palette color)") + thickness = max(MIN_BORDER_THICKNESS, min(MAX_BORDER_THICKNESS, body.border_thickness)) + with frame_locked(db, frame.id): + widget.border_style = body.border_style + widget.border_thickness = thickness + widget.border_color_index = body.border_color_index + db.commit() + return _widget_dict(widget) + + @router.delete("/api/frames/{frame_id}/widgets/{widget_id}") def api_widget_delete( widget_id: int, frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db) diff --git a/server/app/routers/device.py b/server/app/routers/device.py index dc3ebfe..aecc917 100644 --- a/server/app/routers/device.py +++ b/server/app/routers/device.py @@ -25,7 +25,7 @@ from .. import grid, mail, quiet_hours from ..auth import get_server_settings, require_device from ..db import frame_locked, get_db from ..firmware import firmware_path -from ..image_pipeline import logical_render_size, render_panel, render_placeholder +from ..image_pipeline import draw_widget_border, logical_render_size, render_panel, render_placeholder, resolve_border_color from ..models import BatteryLog, Frame, FrameButtonAction, Widget from ..widgets import WIDGET_TYPES from .common import ( @@ -81,11 +81,14 @@ def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = Non def _render_widgets(db: Session, frame: Frame, manage: dict | None, is_normal_wake: bool, as_png: bool = False) -> bytes: """The widget-system compositor: renders every widget on this frame - into its own region (see app/grid.py for grid-cell -> pixel math) and - hands the results to image_pipeline.render_panel for the single - shared paste/enhance/overlay/quantize/pack pass. Replaces the old - per-mode RENDERERS dict -- a frame can now show several widgets at - once instead of exactly one mode owning the whole panel.""" + into its own region (see app/grid.py for grid-cell -> pixel math), + draws that widget's own optional border directly onto its region + (models.Widget.border_style, a shared per-widget property no + widget_type module needs to know about) and hands the results to + image_pipeline.render_panel for the single shared paste/enhance/ + overlay/quantize/pack pass. Replaces the old per-mode RENDERERS + dict -- a frame can now show several widgets at once instead of + exactly one mode owning the whole panel.""" all_widgets = db.scalars( select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order) ).all() @@ -99,6 +102,10 @@ def _render_widgets(db: Session, frame: Frame, manage: dict | None, is_normal_wa frame.orientation, panel_w, panel_h, (widget.x, widget.y, widget.w, widget.h) ) img = module.render(db, frame, widget, pw, ph, is_normal_wake=is_normal_wake) + draw_widget_border( + img, widget.border_style, widget.border_thickness, + resolve_border_color(widget.border_color_index, frame.palette_rgb), + ) regions.append(((px, py, pw, ph), img)) return render_panel( regions, orientation=frame.orientation, palette_rgb=frame.palette_rgb, diff --git a/server/app/routers/frame_pages.py b/server/app/routers/frame_pages.py index 827adc9..5b4f265 100644 --- a/server/app/routers/frame_pages.py +++ b/server/app/routers/frame_pages.py @@ -22,8 +22,12 @@ from ..auth import can_view_frame, current_user from ..calendar_render import CALENDAR_VIEW_LABELS from ..db import get_db from ..image_pipeline import ( + BORDER_STYLES, + BORDER_STYLE_LABELS, DEFAULT_PALETTE_RGB, DISPLAY_MODE_LABELS, + MAX_BORDER_THICKNESS, + MIN_BORDER_THICKNESS, PALETTE_LABELS, STATIC_DISPLAY_MODES, palette_to_hex, @@ -214,11 +218,25 @@ def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session = if widget is None or widget.frame_id != frame.id: raise HTTPException(404, "No such widget") + # Every dialog includes the shared "Border" card (_widget_border_fields.html, + # models.Widget.border_style/border_thickness/border_color_index) -- + # a Widget-level property, not a per-type config field, so this + # context is the same regardless of widget_type. + border_ctx = { + "border_styles": BORDER_STYLES, + "border_style_labels": BORDER_STYLE_LABELS, + "border_color_labels": PALETTE_LABELS, + "default_palette_rgb": DEFAULT_PALETTE_RGB, + "palette_to_hex": palette_to_hex, + "min_border_thickness": MIN_BORDER_THICKNESS, + "max_border_thickness": MAX_BORDER_THICKNESS, + } + if widget.widget_type == "photos": photo_cfg = db.get(PhotoWidgetConfig, widget.id) return templates.TemplateResponse("_widget_dialog_photos.html", { "request": request, "frame": frame, "widget": widget, "photo_cfg": photo_cfg, - "display_mode_labels": DISPLAY_MODE_LABELS, + "display_mode_labels": DISPLAY_MODE_LABELS, **border_ctx, }) if widget.widget_type == "calendar": @@ -229,8 +247,7 @@ def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session = "calendar_users": _calendar_users_for_widget(db, frame.id, widget.id, user.id), "week_start_labels": WEEK_START_LABELS, "calendar_color_labels": PALETTE_LABELS, - "default_palette_rgb": DEFAULT_PALETTE_RGB, - "palette_to_hex": palette_to_hex, + **border_ctx, }) if widget.widget_type == "tasks": @@ -239,8 +256,7 @@ def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session = "request": request, "frame": frame, "widget": widget, "task_cfg": task_cfg, "user": user, "task_users": _task_users_for_widget(db, frame.id, widget.id, user.id), "task_color_labels": PALETTE_LABELS, - "default_palette_rgb": DEFAULT_PALETTE_RGB, - "palette_to_hex": palette_to_hex, + **border_ctx, }) if widget.widget_type == "static": @@ -248,13 +264,14 @@ def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session = return templates.TemplateResponse("_widget_dialog_static.html", { "request": request, "frame": frame, "widget": widget, "static_cfg": static_cfg, "display_mode_labels": {k: v for k, v in DISPLAY_MODE_LABELS.items() if k in STATIC_DISPLAY_MODES}, + **border_ctx, }) 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, - "text_font_families": text_widget.FONT_FAMILIES, + "text_font_families": text_widget.FONT_FAMILIES, **border_ctx, }) if widget.widget_type == "whiteboard": @@ -265,20 +282,20 @@ def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session = return templates.TemplateResponse("_widget_dialog_whiteboard.html", { "request": request, "frame": frame, "widget": widget, "user": user, "whiteboard_source": _whiteboard_source_info(db, whiteboard_cfg), - "viewer_has_webdav_creds": viewer_has_webdav_creds, + "viewer_has_webdav_creds": viewer_has_webdav_creds, **border_ctx, }) if widget.widget_type == "weather": weather_cfg = db.get(WeatherWidgetConfig, widget.id) return templates.TemplateResponse("_widget_dialog_weather.html", { "request": request, "frame": frame, "widget": widget, "weather_cfg": weather_cfg, - "weather_provider_labels": weather.PROVIDER_LABELS, + "weather_provider_labels": weather.PROVIDER_LABELS, **border_ctx, }) if widget.widget_type == "battery": battery_cfg = db.get(BatteryWidgetConfig, widget.id) return templates.TemplateResponse("_widget_dialog_battery.html", { - "request": request, "frame": frame, "widget": widget, "battery_cfg": battery_cfg, + "request": request, "frame": frame, "widget": widget, "battery_cfg": battery_cfg, **border_ctx, }) raise HTTPException(400, f"Unknown widget type: {widget.widget_type}") diff --git a/server/app/static/theme.css b/server/app/static/theme.css index b60c776..5d0e187 100644 --- a/server/app/static/theme.css +++ b/server/app/static/theme.css @@ -364,6 +364,7 @@ input:focus, select:focus { .calendar-user-name { margin: 0; font-size: 13px; font-weight: 600; color: var(--text); } .calendar-row { flex-wrap: wrap; } .calendar-color-picker { display: inline-flex; align-items: center; gap: 5px; margin-left: 8px; } +.border-color-picker { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: 4px; } .color-swatch { width: 20px; height: 20px; padding: 0; margin: 0; border: 2px solid var(--border); border-radius: 5px; diff --git a/server/app/static/widget_dialog_battery.js b/server/app/static/widget_dialog_battery.js index a982970..d371c76 100644 --- a/server/app/static/widget_dialog_battery.js +++ b/server/app/static/widget_dialog_battery.js @@ -30,6 +30,7 @@ function initBatteryDialog() { document.getElementById('battery-preview-refresh').addEventListener('click', loadBatteryPreview); loadBatteryPreview(); + initBorderFields(); } function closeBatteryDialog() { diff --git a/server/app/static/widget_dialog_border.js b/server/app/static/widget_dialog_border.js new file mode 100644 index 0000000..06ea639 --- /dev/null +++ b/server/app/static/widget_dialog_border.js @@ -0,0 +1,48 @@ +// Shared "Border" card (models.Widget.border_style/border_thickness/ +// border_color_index, _widget_border_fields.html) -- present on every +// widget type's dialog regardless of widget_type, so this is one shared +// init function each widget_dialog_.js's initDialog() calls, +// rather than 8 copies of the same slider/swatch/save wiring. Not a +// page-load script by itself -- frame_layout.js loads it unconditionally +// (like every other widget_dialog_*.js) since which dialog is open, and +// therefore which initDialog() calls initBorderFields(), varies. + +function initBorderFields() { + const styleSelect = document.getElementById('border_style'); + if (!styleSelect) return; // dialog fragment didn't render the border card -- shouldn't happen + + const thickness = document.getElementById('border_thickness'); + const thicknessValue = document.getElementById('border_thickness_value'); + thickness.addEventListener('input', (e) => { + thicknessValue.textContent = `${e.target.value}px`; + }); + + const colorIndexInput = document.getElementById('border_color_index'); + document.querySelectorAll('#border-color-picker .color-swatch').forEach((btn) => { + btn.addEventListener('click', () => { + document.querySelectorAll('#border-color-picker .color-swatch').forEach((el) => el.classList.remove('selected')); + btn.classList.add('selected'); + colorIndexInput.value = btn.dataset.index; + }); + }); + + document.getElementById('border-config-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const body = JSON.stringify({ + border_style: styleSelect.value, + border_thickness: Number(thickness.value), + border_color_index: Number(colorIndexInput.value), + }); + try { + const resp = await fetch(`${window.FRAME_API}/border`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + }); + if (!resp.ok) throw new Error(await apiError(resp)); + showStatus(true, 'Border saved.'); + } catch (err) { + showStatus(false, err.message); + } + }); +} diff --git a/server/app/static/widget_dialog_calendar.js b/server/app/static/widget_dialog_calendar.js index 1a30ca8..c1edcc8 100644 --- a/server/app/static/widget_dialog_calendar.js +++ b/server/app/static/widget_dialog_calendar.js @@ -197,6 +197,7 @@ function initCalendarDialog() { document.getElementById('calendar-preview-refresh').addEventListener('click', loadCalendarPreview); loadCalendarPreview(); + initBorderFields(); } function closeCalendarDialog() { diff --git a/server/app/static/widget_dialog_photos.js b/server/app/static/widget_dialog_photos.js index 39fc778..db81a0b 100644 --- a/server/app/static/widget_dialog_photos.js +++ b/server/app/static/widget_dialog_photos.js @@ -113,6 +113,7 @@ function initPhotosDialog() { // Slow poll: picks up real changes (new photo displayed, queue edited // from elsewhere) without a manual refresh. Skipped mid-drag. photosPollTimer = setInterval(loadQueue, 10000); + initBorderFields(); } function closePhotosDialog() { diff --git a/server/app/static/widget_dialog_static.js b/server/app/static/widget_dialog_static.js index 9885b13..5181f4a 100644 --- a/server/app/static/widget_dialog_static.js +++ b/server/app/static/widget_dialog_static.js @@ -55,6 +55,7 @@ function initStaticDialog() { document.getElementById('static-preview-refresh').addEventListener('click', loadStaticPreview); loadStaticPreview(); + initBorderFields(); } function closeStaticDialog() { diff --git a/server/app/static/widget_dialog_tasks.js b/server/app/static/widget_dialog_tasks.js index 80c2e9f..fd2ca41 100644 --- a/server/app/static/widget_dialog_tasks.js +++ b/server/app/static/widget_dialog_tasks.js @@ -88,6 +88,7 @@ function initTasksDialog() { document.getElementById('tasks-preview-refresh').addEventListener('click', loadTasksPreview); loadTasksPreview(); + initBorderFields(); } function closeTasksDialog() { diff --git a/server/app/static/widget_dialog_text.js b/server/app/static/widget_dialog_text.js index fc83d5b..4a12a55 100644 --- a/server/app/static/widget_dialog_text.js +++ b/server/app/static/widget_dialog_text.js @@ -130,6 +130,7 @@ function initTextDialog() { document.getElementById('text-preview-refresh').addEventListener('click', loadTextPreview); loadTextPreview(); + initBorderFields(); } function closeTextDialog() { diff --git a/server/app/static/widget_dialog_weather.js b/server/app/static/widget_dialog_weather.js index 430bb25..3d23821 100644 --- a/server/app/static/widget_dialog_weather.js +++ b/server/app/static/widget_dialog_weather.js @@ -151,6 +151,7 @@ function initWeatherDialog() { document.getElementById('weather-preview-refresh').addEventListener('click', () => loadWeatherPreview(true)); loadWeatherPreview(false); + initBorderFields(); } function closeWeatherDialog() { diff --git a/server/app/static/widget_dialog_whiteboard.js b/server/app/static/widget_dialog_whiteboard.js index 14f9cdf..870bd62 100644 --- a/server/app/static/widget_dialog_whiteboard.js +++ b/server/app/static/widget_dialog_whiteboard.js @@ -96,6 +96,7 @@ function initWhiteboardDialog() { // whiteboard's `force` param). document.getElementById('whiteboard-preview-refresh').addEventListener('click', () => loadWhiteboardPreview(true)); loadWhiteboardPreview(false); + initBorderFields(); // --- file picker (Browse...) --- const browseToggle = document.getElementById('whiteboard-browse-toggle'); diff --git a/server/app/templates/_widget_border_fields.html b/server/app/templates/_widget_border_fields.html new file mode 100644 index 0000000..fdfa6a6 --- /dev/null +++ b/server/app/templates/_widget_border_fields.html @@ -0,0 +1,32 @@ +
+

Border

+

An optional border drawn around this widget's own box -- + "None" (the default) draws nothing. Shows on the frame's actual live + view, not in the standalone preview below.

+
+ + + + {% set current_palette = frame.palette_rgb or default_palette_rgb %} + {% set current_hex = palette_to_hex(current_palette) %} +
+ {% for label in border_color_labels %} + + {% endfor %} +
+ + +
+
diff --git a/server/app/templates/_widget_dialog_battery.html b/server/app/templates/_widget_dialog_battery.html index c2cd274..e633ec3 100644 --- a/server/app/templates/_widget_dialog_battery.html +++ b/server/app/templates/_widget_dialog_battery.html @@ -15,6 +15,8 @@ +{% include "_widget_border_fields.html" %} +

Preview

How this widget currently renders.

diff --git a/server/app/templates/_widget_dialog_calendar.html b/server/app/templates/_widget_dialog_calendar.html index 23386a7..f42eb56 100644 --- a/server/app/templates/_widget_dialog_calendar.html +++ b/server/app/templates/_widget_dialog_calendar.html @@ -127,6 +127,8 @@
+{% include "_widget_border_fields.html" %} +

Preview

How this widget currently renders.

diff --git a/server/app/templates/_widget_dialog_photos.html b/server/app/templates/_widget_dialog_photos.html index fc0bb77..8296682 100644 --- a/server/app/templates/_widget_dialog_photos.html +++ b/server/app/templates/_widget_dialog_photos.html @@ -41,6 +41,8 @@
+{% include "_widget_border_fields.html" %} +

Now displaying

Loading...

diff --git a/server/app/templates/_widget_dialog_static.html b/server/app/templates/_widget_dialog_static.html index d85f4a9..37bbb1d 100644 --- a/server/app/templates/_widget_dialog_static.html +++ b/server/app/templates/_widget_dialog_static.html @@ -36,6 +36,8 @@
+{% include "_widget_border_fields.html" %} +

Preview

How this widget currently renders.

diff --git a/server/app/templates/_widget_dialog_tasks.html b/server/app/templates/_widget_dialog_tasks.html index f30262a..f6ba409 100644 --- a/server/app/templates/_widget_dialog_tasks.html +++ b/server/app/templates/_widget_dialog_tasks.html @@ -59,6 +59,8 @@
+{% include "_widget_border_fields.html" %} +

Preview

How this widget currently renders.

diff --git a/server/app/templates/_widget_dialog_text.html b/server/app/templates/_widget_dialog_text.html index 5d7acee..6fd1f97 100644 --- a/server/app/templates/_widget_dialog_text.html +++ b/server/app/templates/_widget_dialog_text.html @@ -47,6 +47,8 @@
+{% include "_widget_border_fields.html" %} +

Preview

How this widget currently renders.

diff --git a/server/app/templates/_widget_dialog_weather.html b/server/app/templates/_widget_dialog_weather.html index 3efe73d..e30edcd 100644 --- a/server/app/templates/_widget_dialog_weather.html +++ b/server/app/templates/_widget_dialog_weather.html @@ -73,6 +73,8 @@
+{% include "_widget_border_fields.html" %} +

Preview

How this widget currently renders.

diff --git a/server/app/templates/_widget_dialog_whiteboard.html b/server/app/templates/_widget_dialog_whiteboard.html index 8de42d8..1336a0c 100644 --- a/server/app/templates/_widget_dialog_whiteboard.html +++ b/server/app/templates/_widget_dialog_whiteboard.html @@ -50,6 +50,8 @@ {% endif %}
+{% include "_widget_border_fields.html" %} +

Preview

How this widget currently renders.

diff --git a/server/app/templates/frame_layout.html b/server/app/templates/frame_layout.html index cc3b163..f9878c7 100644 --- a/server/app/templates/frame_layout.html +++ b/server/app/templates/frame_layout.html @@ -76,6 +76,7 @@ + {% endblock %} diff --git a/server/tests/test_migrations.py b/server/tests/test_migrations.py index e7bbe75..efc2de4 100644 --- a/server/tests/test_migrations.py +++ b/server/tests/test_migrations.py @@ -85,6 +85,8 @@ def test_expected_columns_exist_on_current_schema(): assert "battery_widget_configs" in inspector.get_table_names() # migration 25 battery_widget_columns = {c["name"] for c in inspector.get_columns("battery_widget_configs")} assert "mode" in battery_widget_columns + widget_columns = {c["name"] for c in inspector.get_columns("widgets")} + assert {"border_style", "border_thickness", "border_color_index"} <= widget_columns # migration 26 # --- widget system backfill (migration 16 + _ensure_widgets_backfilled) --- diff --git a/server/tests/test_widget_border.py b/server/tests/test_widget_border.py new file mode 100644 index 0000000..025c8b3 --- /dev/null +++ b/server/tests/test_widget_border.py @@ -0,0 +1,149 @@ +"""routers/api_widgets.py's POST .../border endpoint (models.Widget. +border_style/border_thickness/border_color_index) -- a Widget-level +property, not a per-type config field, so this is exercised independently +of any specific widget_type. End-to-end rendering (does a saved border +actually show up in the composited panel) is covered separately in +test_device_widget_dispatch.py-style tests below via the frame preview +endpoint, the same real render_panel path /frame/image uses.""" + +from __future__ import annotations + +import io + +from PIL import Image + +from app.models import Frame, Widget + +from .conftest import csrf_headers, link_user, login, make_user + + +def _widget_id(db_session, widget_type="photos") -> int: + return db_session.query(Widget).filter_by(frame_id=1, widget_type=widget_type).one().id + + +def test_new_widget_defaults_to_no_border(db_session): + widget = db_session.query(Widget).filter_by(frame_id=1).one() + assert widget.border_style == "none" + assert widget.border_thickness == 3 + assert widget.border_color_index == 0 + + +def test_set_border_persists(client, db_session): + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + widget_id = _widget_id(db_session) + resp = client.post(f"/api/frames/1/widgets/{widget_id}/border", + json={"border_style": "dashed", "border_thickness": 5, "border_color_index": 3}, + headers=csrf_headers(client)) + assert resp.status_code == 200, resp.text + assert resp.json() == { + "id": widget_id, "widget_type": "photos", "x": 0, "y": 0, "w": 8, "h": 5, "sort_order": 0, + "border_style": "dashed", "border_thickness": 5, "border_color_index": 3, + } + widget = db_session.get(Widget, widget_id) + assert (widget.border_style, widget.border_thickness, widget.border_color_index) == ("dashed", 5, 3) + + +def test_set_border_thickness_clamps_to_range(client, db_session): + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + widget_id = _widget_id(db_session) + resp = client.post(f"/api/frames/1/widgets/{widget_id}/border", + json={"border_style": "solid", "border_thickness": 999, "border_color_index": 0}, + headers=csrf_headers(client)) + assert resp.status_code == 200, resp.text + assert resp.json()["border_thickness"] == 8 # image_pipeline.MAX_BORDER_THICKNESS + + resp = client.post(f"/api/frames/1/widgets/{widget_id}/border", + json={"border_style": "solid", "border_thickness": -5, "border_color_index": 0}, + headers=csrf_headers(client)) + assert resp.status_code == 200, resp.text + assert resp.json()["border_thickness"] == 1 # image_pipeline.MIN_BORDER_THICKNESS + + +def test_set_border_rejects_unknown_style(client, db_session): + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + widget_id = _widget_id(db_session) + resp = client.post(f"/api/frames/1/widgets/{widget_id}/border", + json={"border_style": "sparkly", "border_thickness": 3, "border_color_index": 0}, + headers=csrf_headers(client)) + assert resp.status_code == 400 + assert "border_style" in resp.json()["detail"] + + +def test_set_border_rejects_out_of_range_color_index(client, db_session): + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + widget_id = _widget_id(db_session) + resp = client.post(f"/api/frames/1/widgets/{widget_id}/border", + json={"border_style": "solid", "border_thickness": 3, "border_color_index": 6}, + headers=csrf_headers(client)) + assert resp.status_code == 400 + assert "border_color_index" in resp.json()["detail"] + + +def test_set_border_404s_for_unknown_widget(client, db_session): + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + resp = client.post("/api/frames/1/widgets/999999/border", + json={"border_style": "solid", "border_thickness": 3, "border_color_index": 0}, + headers=csrf_headers(client)) + assert resp.status_code == 404 + + +def test_set_border_unrelated_user_404s(client, db_session): + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + make_user(db_session, "mallory") + widget_id = _widget_id(db_session) + + client.cookies.clear() + login(client, "mallory") + resp = client.post(f"/api/frames/1/widgets/{widget_id}/border", + json={"border_style": "solid", "border_thickness": 3, "border_color_index": 0}, + headers=csrf_headers(client)) + assert resp.status_code == 404 + assert db_session.get(Widget, widget_id).border_style == "none" + + +def test_set_border_linked_but_not_controlling_user_409s(client, db_session): + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + bob = make_user(db_session, "bob") + frame = db_session.get(Frame, 1) + link_user(db_session, bob, frame) + widget_id = _widget_id(db_session) + + client.cookies.clear() + login(client, "bob") + resp = client.post(f"/api/frames/1/widgets/{widget_id}/border", + json={"border_style": "solid", "border_thickness": 3, "border_color_index": 0}, + headers=csrf_headers(client)) + assert resp.status_code == 409 + assert resp.json()["detail"]["error"] == "not_controller" + + +# --- actually renders (real render_panel path, same as /frame/image) ------ + +def _preview_pixels(client) -> Image.Image: + resp = client.get("/api/frames/1/preview") + assert resp.status_code == 200, resp.text + return Image.open(io.BytesIO(resp.content)).convert("RGB") + + +def test_solid_border_draws_the_configured_palette_color(client, db_session): + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + widget_id = _widget_id(db_session) + # color_index 3 == Red, see image_pipeline.PALETTE_LABELS/DEFAULT_PALETTE_RGB + resp = client.post(f"/api/frames/1/widgets/{widget_id}/border", + json={"border_style": "solid", "border_thickness": 6, "border_color_index": 3}, + headers=csrf_headers(client)) + assert resp.status_code == 200, resp.text + + img = _preview_pixels(client) + assert img.getpixel((0, 0)) == (207, 0, 15) # DEFAULT_PALETTE_RGB[3], top-left corner of the stroke + + +def test_no_border_leaves_the_edge_unmarked(client, db_session): + """The default "none" style is a true no-op -- this is the negative + case for test_solid_border_draws_the_configured_palette_color, using + the exact same photo-widget fixture minus the border, so a red edge + pixel there can only be the border, not something else in the + render path already painting it that color.""" + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + img = _preview_pixels(client) + assert img.getpixel((0, 0)) != (207, 0, 15)