Add per-widget border option (style, thickness, palette color)
Build and push server image / test (push) Has been cancelled
Build and push server image / build-and-push (push) Has been cancelled
Build and push server image / deploy (push) Has been cancelled

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.
This commit is contained in:
2026-07-27 19:51:04 +00:00
parent eb7127718b
commit b15747a604
30 changed files with 503 additions and 19 deletions
-1
View File
@@ -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
+16
View File
@@ -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`,
+108
View File
@@ -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 <input type="color"> always sends, but a
+31 -2
View File
@@ -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),
]
+14
View File
@@ -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"),)
+38 -1
View File
@@ -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)
+13 -6
View File
@@ -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,
+26 -9
View File
@@ -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}")
+1
View File
@@ -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;
@@ -30,6 +30,7 @@ function initBatteryDialog() {
document.getElementById('battery-preview-refresh').addEventListener('click', loadBatteryPreview);
loadBatteryPreview();
initBorderFields();
}
function closeBatteryDialog() {
+48
View File
@@ -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_<type>.js's init<Type>Dialog() 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 init<Type>Dialog() 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);
}
});
}
@@ -197,6 +197,7 @@ function initCalendarDialog() {
document.getElementById('calendar-preview-refresh').addEventListener('click', loadCalendarPreview);
loadCalendarPreview();
initBorderFields();
}
function closeCalendarDialog() {
@@ -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() {
@@ -55,6 +55,7 @@ function initStaticDialog() {
document.getElementById('static-preview-refresh').addEventListener('click', loadStaticPreview);
loadStaticPreview();
initBorderFields();
}
function closeStaticDialog() {
+1
View File
@@ -88,6 +88,7 @@ function initTasksDialog() {
document.getElementById('tasks-preview-refresh').addEventListener('click', loadTasksPreview);
loadTasksPreview();
initBorderFields();
}
function closeTasksDialog() {
+1
View File
@@ -130,6 +130,7 @@ function initTextDialog() {
document.getElementById('text-preview-refresh').addEventListener('click', loadTextPreview);
loadTextPreview();
initBorderFields();
}
function closeTextDialog() {
@@ -151,6 +151,7 @@ function initWeatherDialog() {
document.getElementById('weather-preview-refresh').addEventListener('click', () => loadWeatherPreview(true));
loadWeatherPreview(false);
initBorderFields();
}
function closeWeatherDialog() {
@@ -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');
@@ -0,0 +1,32 @@
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Border</h2>
<p class="sub">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.</p>
<form id="border-config-form">
<label>Style
<select id="border_style">
{% for style in border_styles %}
<option value="{{ style }}" {% if widget.border_style == style %}selected{% endif %}>{{ border_style_labels[style] }}</option>
{% endfor %}
</select>
</label>
<label>Thickness
<input type="range" id="border_thickness" min="{{ min_border_thickness }}" max="{{ max_border_thickness }}" step="1"
value="{{ widget.border_thickness }}">
<span class="slider-value" id="border_thickness_value">{{ widget.border_thickness }}px</span>
</label>
<label>Color</label>
{% set current_palette = frame.palette_rgb or default_palette_rgb %}
{% set current_hex = palette_to_hex(current_palette) %}
<div class="border-color-picker" id="border-color-picker">
{% for label in border_color_labels %}
<button type="button" class="color-swatch {% if widget.border_color_index == loop.index0 %}selected{% endif %}"
data-index="{{ loop.index0 }}" title="{{ label }}"
style="background-color: {{ current_hex[loop.index0] }};"></button>
{% endfor %}
</div>
<input type="hidden" id="border_color_index" value="{{ widget.border_color_index }}">
<button type="submit">Save</button>
</form>
</section>
@@ -15,6 +15,8 @@
</form>
</section>
{% include "_widget_border_fields.html" %}
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Preview</h2>
<p class="sub">How this widget currently renders.</p>
@@ -127,6 +127,8 @@
</div>
</section>
{% include "_widget_border_fields.html" %}
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Preview</h2>
<p class="sub">How this widget currently renders.</p>
@@ -41,6 +41,8 @@
</form>
</section>
{% include "_widget_border_fields.html" %}
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Now displaying</h2>
<div id="current-thumb"><p class="sub">Loading...</p></div>
@@ -36,6 +36,8 @@
</form>
</section>
{% include "_widget_border_fields.html" %}
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Preview</h2>
<p class="sub">How this widget currently renders.</p>
@@ -59,6 +59,8 @@
</form>
</section>
{% include "_widget_border_fields.html" %}
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Preview</h2>
<p class="sub">How this widget currently renders.</p>
@@ -47,6 +47,8 @@
</form>
</section>
{% include "_widget_border_fields.html" %}
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Preview</h2>
<p class="sub">How this widget currently renders.</p>
@@ -73,6 +73,8 @@
</div>
</section>
{% include "_widget_border_fields.html" %}
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Preview</h2>
<p class="sub">How this widget currently renders.</p>
@@ -50,6 +50,8 @@
{% endif %}
</section>
{% include "_widget_border_fields.html" %}
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Preview</h2>
<p class="sub">How this widget currently renders.</p>
+1
View File
@@ -76,6 +76,7 @@
<script src="/static/widget_dialog_text.js"></script>
<script src="/static/widget_dialog_weather.js"></script>
<script src="/static/widget_dialog_battery.js"></script>
<script src="/static/widget_dialog_border.js"></script>
<script src="/static/frame_layout.js"></script>
<script src="/static/saved_layouts.js"></script>
{% endblock %}
+2
View File
@@ -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) ---
+149
View File
@@ -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)