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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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
@@ -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),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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"),)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -88,6 +88,7 @@ function initTasksDialog() {
|
||||
|
||||
document.getElementById('tasks-preview-refresh').addEventListener('click', loadTasksPreview);
|
||||
loadTasksPreview();
|
||||
initBorderFields();
|
||||
}
|
||||
|
||||
function closeTasksDialog() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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) ---
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user