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
|
CURRENT TODO
|
||||||
-add more actions for buttons (i.e. change widget/layout)
|
-add more actions for buttons (i.e. change widget/layout)
|
||||||
-Fix spurious button assignment stuff (probably but buttons on widget config with sane defaults)
|
-Fix spurious button assignment stuff (probably but buttons on widget config with sane defaults)
|
||||||
-widget border option
|
|
||||||
-battery life widget
|
-battery life widget
|
||||||
-sharing layouts with linked users
|
-sharing layouts with linked users
|
||||||
-a "coming up this week" widget
|
-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
|
`routers/api_widgets.py`, re-validated regardless of what the client
|
||||||
already checked) -- that's what keeps compositing simple: no z-order,
|
already checked) -- that's what keeps compositing simple: no z-order,
|
||||||
no blending, just N independent regions pasted onto one shared canvas.
|
no blending, just N independent regions pasted onto one shared canvas.
|
||||||
|
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`,
|
- Per-type 1:1 extension tables -- `PhotoWidgetConfig`,
|
||||||
`CalendarWidgetConfig`, `WhiteboardWidgetConfig`, `TaskWidgetConfig`,
|
`CalendarWidgetConfig`, `WhiteboardWidgetConfig`, `TaskWidgetConfig`,
|
||||||
`StaticWidgetConfig`, `TextWidgetConfig`, `WeatherWidgetConfig`,
|
`StaticWidgetConfig`, `TextWidgetConfig`, `WeatherWidgetConfig`,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import io
|
import io
|
||||||
|
import math
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageEnhance, ImageFont, ImageOps
|
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)
|
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)
|
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
|
# How each orientation maps the logically-composed image onto the native
|
||||||
# 800x480 panel. "portrait"/"portrait_flipped" compose at 480x800 (so the
|
# 800x480 panel. "portrait"/"portrait_flipped" compose at 480x800 (so the
|
||||||
# crop ratio matches how the frame actually hangs) and rotate into native
|
# 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.
|
# upstream.
|
||||||
PANEL_CODES = [0x0, 0x1, 0x2, 0x3, 0x5, 0x6]
|
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]:
|
def palette_to_hex(palette_rgb: list) -> list[str]:
|
||||||
"""[(0,0,0), ...] -> ["#000000", ...], for pre-filling the Advanced
|
"""[(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]
|
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:
|
def hex_to_rgb(hex_str: str) -> tuple[int, int, int] | None:
|
||||||
""""#1a2b3c" -> (26, 43, 60), or None for anything that isn't exactly
|
""""#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
|
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"
|
"SELECT COALESCE(MAX(sort_order), 0) FROM widgets WHERE frame_id = :frame_id"
|
||||||
), {"frame_id": row["frame_id"]}).scalar()
|
), {"frame_id": row["frame_id"]}).scalar()
|
||||||
result = conn.execute(text(
|
result = conn.execute(text(
|
||||||
"INSERT INTO widgets (frame_id, widget_type, x, y, w, h, sort_order, created_at) "
|
"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)"
|
"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,
|
), {"frame_id": row["frame_id"], "x": x, "y": y, "w": w, "h": h,
|
||||||
"sort_order": max_sort + 1, "created_at": now})
|
"sort_order": max_sort + 1, "created_at": now})
|
||||||
new_widget_id = result.lastrowid
|
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 = [
|
MIGRATIONS = [
|
||||||
(1, _migration_1),
|
(1, _migration_1),
|
||||||
(2, _migration_2),
|
(2, _migration_2),
|
||||||
@@ -703,6 +731,7 @@ MIGRATIONS = [
|
|||||||
(23, _migration_23),
|
(23, _migration_23),
|
||||||
(24, _migration_24),
|
(24, _migration_24),
|
||||||
(25, _migration_25),
|
(25, _migration_25),
|
||||||
|
(26, _migration_26),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -445,6 +445,20 @@ class Widget(Base):
|
|||||||
# table needs to match a specific attribute name here.
|
# table needs to match a specific attribute name here.
|
||||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
created_at: Mapped[float] = mapped_column(Float, default=time.time)
|
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"),)
|
__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 ..auth import require_frame_control, require_frame_view, require_user_api
|
||||||
from ..db import frame_locked, get_db, widget_locked
|
from ..db import frame_locked, get_db, widget_locked
|
||||||
from ..image_pipeline import (
|
from ..image_pipeline import (
|
||||||
|
BORDER_STYLES,
|
||||||
DEFAULT_DISPLAY_MODE,
|
DEFAULT_DISPLAY_MODE,
|
||||||
DEFAULT_STATIC_DISPLAY_MODE,
|
DEFAULT_STATIC_DISPLAY_MODE,
|
||||||
DISPLAY_MODES,
|
DISPLAY_MODES,
|
||||||
hex_to_rgb,
|
hex_to_rgb,
|
||||||
|
MAX_BORDER_THICKNESS,
|
||||||
|
MIN_BORDER_THICKNESS,
|
||||||
|
PALETTE_LABELS,
|
||||||
STATIC_DISPLAY_MODES,
|
STATIC_DISPLAY_MODES,
|
||||||
render_preview_png,
|
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:
|
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,
|
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(
|
def require_widget_view(
|
||||||
@@ -226,6 +231,38 @@ def api_widget_move(
|
|||||||
return _widget_dict(widget)
|
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}")
|
@router.delete("/api/frames/{frame_id}/widgets/{widget_id}")
|
||||||
def api_widget_delete(
|
def api_widget_delete(
|
||||||
widget_id: int, frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db)
|
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 ..auth import get_server_settings, require_device
|
||||||
from ..db import frame_locked, get_db
|
from ..db import frame_locked, get_db
|
||||||
from ..firmware import firmware_path
|
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 ..models import BatteryLog, Frame, FrameButtonAction, Widget
|
||||||
from ..widgets import WIDGET_TYPES
|
from ..widgets import WIDGET_TYPES
|
||||||
from .common import (
|
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,
|
def _render_widgets(db: Session, frame: Frame, manage: dict | None, is_normal_wake: bool,
|
||||||
as_png: bool = False) -> bytes:
|
as_png: bool = False) -> bytes:
|
||||||
"""The widget-system compositor: renders every widget on this frame
|
"""The widget-system compositor: renders every widget on this frame
|
||||||
into its own region (see app/grid.py for grid-cell -> pixel math) and
|
into its own region (see app/grid.py for grid-cell -> pixel math),
|
||||||
hands the results to image_pipeline.render_panel for the single
|
draws that widget's own optional border directly onto its region
|
||||||
shared paste/enhance/overlay/quantize/pack pass. Replaces the old
|
(models.Widget.border_style, a shared per-widget property no
|
||||||
per-mode RENDERERS dict -- a frame can now show several widgets at
|
widget_type module needs to know about) and hands the results to
|
||||||
once instead of exactly one mode owning the whole panel."""
|
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(
|
all_widgets = db.scalars(
|
||||||
select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order)
|
select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order)
|
||||||
).all()
|
).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)
|
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)
|
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))
|
regions.append(((px, py, pw, ph), img))
|
||||||
return render_panel(
|
return render_panel(
|
||||||
regions, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
|
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 ..calendar_render import CALENDAR_VIEW_LABELS
|
||||||
from ..db import get_db
|
from ..db import get_db
|
||||||
from ..image_pipeline import (
|
from ..image_pipeline import (
|
||||||
|
BORDER_STYLES,
|
||||||
|
BORDER_STYLE_LABELS,
|
||||||
DEFAULT_PALETTE_RGB,
|
DEFAULT_PALETTE_RGB,
|
||||||
DISPLAY_MODE_LABELS,
|
DISPLAY_MODE_LABELS,
|
||||||
|
MAX_BORDER_THICKNESS,
|
||||||
|
MIN_BORDER_THICKNESS,
|
||||||
PALETTE_LABELS,
|
PALETTE_LABELS,
|
||||||
STATIC_DISPLAY_MODES,
|
STATIC_DISPLAY_MODES,
|
||||||
palette_to_hex,
|
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:
|
if widget is None or widget.frame_id != frame.id:
|
||||||
raise HTTPException(404, "No such widget")
|
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":
|
if widget.widget_type == "photos":
|
||||||
photo_cfg = db.get(PhotoWidgetConfig, widget.id)
|
photo_cfg = db.get(PhotoWidgetConfig, widget.id)
|
||||||
return templates.TemplateResponse("_widget_dialog_photos.html", {
|
return templates.TemplateResponse("_widget_dialog_photos.html", {
|
||||||
"request": request, "frame": frame, "widget": widget, "photo_cfg": photo_cfg,
|
"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":
|
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),
|
"calendar_users": _calendar_users_for_widget(db, frame.id, widget.id, user.id),
|
||||||
"week_start_labels": WEEK_START_LABELS,
|
"week_start_labels": WEEK_START_LABELS,
|
||||||
"calendar_color_labels": PALETTE_LABELS,
|
"calendar_color_labels": PALETTE_LABELS,
|
||||||
"default_palette_rgb": DEFAULT_PALETTE_RGB,
|
**border_ctx,
|
||||||
"palette_to_hex": palette_to_hex,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if widget.widget_type == "tasks":
|
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,
|
"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_users": _task_users_for_widget(db, frame.id, widget.id, user.id),
|
||||||
"task_color_labels": PALETTE_LABELS,
|
"task_color_labels": PALETTE_LABELS,
|
||||||
"default_palette_rgb": DEFAULT_PALETTE_RGB,
|
**border_ctx,
|
||||||
"palette_to_hex": palette_to_hex,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if widget.widget_type == "static":
|
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", {
|
return templates.TemplateResponse("_widget_dialog_static.html", {
|
||||||
"request": request, "frame": frame, "widget": widget, "static_cfg": static_cfg,
|
"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},
|
"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":
|
if widget.widget_type == "text":
|
||||||
text_cfg = db.get(TextWidgetConfig, widget.id)
|
text_cfg = db.get(TextWidgetConfig, widget.id)
|
||||||
return templates.TemplateResponse("_widget_dialog_text.html", {
|
return templates.TemplateResponse("_widget_dialog_text.html", {
|
||||||
"request": request, "frame": frame, "widget": widget, "text_cfg": text_cfg,
|
"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":
|
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", {
|
return templates.TemplateResponse("_widget_dialog_whiteboard.html", {
|
||||||
"request": request, "frame": frame, "widget": widget, "user": user,
|
"request": request, "frame": frame, "widget": widget, "user": user,
|
||||||
"whiteboard_source": _whiteboard_source_info(db, whiteboard_cfg),
|
"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":
|
if widget.widget_type == "weather":
|
||||||
weather_cfg = db.get(WeatherWidgetConfig, widget.id)
|
weather_cfg = db.get(WeatherWidgetConfig, widget.id)
|
||||||
return templates.TemplateResponse("_widget_dialog_weather.html", {
|
return templates.TemplateResponse("_widget_dialog_weather.html", {
|
||||||
"request": request, "frame": frame, "widget": widget, "weather_cfg": weather_cfg,
|
"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":
|
if widget.widget_type == "battery":
|
||||||
battery_cfg = db.get(BatteryWidgetConfig, widget.id)
|
battery_cfg = db.get(BatteryWidgetConfig, widget.id)
|
||||||
return templates.TemplateResponse("_widget_dialog_battery.html", {
|
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}")
|
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-user-name { margin: 0; font-size: 13px; font-weight: 600; color: var(--text); }
|
||||||
.calendar-row { flex-wrap: wrap; }
|
.calendar-row { flex-wrap: wrap; }
|
||||||
.calendar-color-picker { display: inline-flex; align-items: center; gap: 5px; margin-left: 8px; }
|
.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 {
|
.color-swatch {
|
||||||
width: 20px; height: 20px; padding: 0; margin: 0;
|
width: 20px; height: 20px; padding: 0; margin: 0;
|
||||||
border: 2px solid var(--border); border-radius: 5px;
|
border: 2px solid var(--border); border-radius: 5px;
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ function initBatteryDialog() {
|
|||||||
|
|
||||||
document.getElementById('battery-preview-refresh').addEventListener('click', loadBatteryPreview);
|
document.getElementById('battery-preview-refresh').addEventListener('click', loadBatteryPreview);
|
||||||
loadBatteryPreview();
|
loadBatteryPreview();
|
||||||
|
initBorderFields();
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeBatteryDialog() {
|
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);
|
document.getElementById('calendar-preview-refresh').addEventListener('click', loadCalendarPreview);
|
||||||
loadCalendarPreview();
|
loadCalendarPreview();
|
||||||
|
initBorderFields();
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeCalendarDialog() {
|
function closeCalendarDialog() {
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ function initPhotosDialog() {
|
|||||||
// Slow poll: picks up real changes (new photo displayed, queue edited
|
// Slow poll: picks up real changes (new photo displayed, queue edited
|
||||||
// from elsewhere) without a manual refresh. Skipped mid-drag.
|
// from elsewhere) without a manual refresh. Skipped mid-drag.
|
||||||
photosPollTimer = setInterval(loadQueue, 10000);
|
photosPollTimer = setInterval(loadQueue, 10000);
|
||||||
|
initBorderFields();
|
||||||
}
|
}
|
||||||
|
|
||||||
function closePhotosDialog() {
|
function closePhotosDialog() {
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ function initStaticDialog() {
|
|||||||
|
|
||||||
document.getElementById('static-preview-refresh').addEventListener('click', loadStaticPreview);
|
document.getElementById('static-preview-refresh').addEventListener('click', loadStaticPreview);
|
||||||
loadStaticPreview();
|
loadStaticPreview();
|
||||||
|
initBorderFields();
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeStaticDialog() {
|
function closeStaticDialog() {
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ function initTasksDialog() {
|
|||||||
|
|
||||||
document.getElementById('tasks-preview-refresh').addEventListener('click', loadTasksPreview);
|
document.getElementById('tasks-preview-refresh').addEventListener('click', loadTasksPreview);
|
||||||
loadTasksPreview();
|
loadTasksPreview();
|
||||||
|
initBorderFields();
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeTasksDialog() {
|
function closeTasksDialog() {
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ function initTextDialog() {
|
|||||||
|
|
||||||
document.getElementById('text-preview-refresh').addEventListener('click', loadTextPreview);
|
document.getElementById('text-preview-refresh').addEventListener('click', loadTextPreview);
|
||||||
loadTextPreview();
|
loadTextPreview();
|
||||||
|
initBorderFields();
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeTextDialog() {
|
function closeTextDialog() {
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ function initWeatherDialog() {
|
|||||||
|
|
||||||
document.getElementById('weather-preview-refresh').addEventListener('click', () => loadWeatherPreview(true));
|
document.getElementById('weather-preview-refresh').addEventListener('click', () => loadWeatherPreview(true));
|
||||||
loadWeatherPreview(false);
|
loadWeatherPreview(false);
|
||||||
|
initBorderFields();
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeWeatherDialog() {
|
function closeWeatherDialog() {
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ function initWhiteboardDialog() {
|
|||||||
// whiteboard's `force` param).
|
// whiteboard's `force` param).
|
||||||
document.getElementById('whiteboard-preview-refresh').addEventListener('click', () => loadWhiteboardPreview(true));
|
document.getElementById('whiteboard-preview-refresh').addEventListener('click', () => loadWhiteboardPreview(true));
|
||||||
loadWhiteboardPreview(false);
|
loadWhiteboardPreview(false);
|
||||||
|
initBorderFields();
|
||||||
|
|
||||||
// --- file picker (Browse...) ---
|
// --- file picker (Browse...) ---
|
||||||
const browseToggle = document.getElementById('whiteboard-browse-toggle');
|
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>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{% include "_widget_border_fields.html" %}
|
||||||
|
|
||||||
<section class="card" style="margin-top: 20px;">
|
<section class="card" style="margin-top: 20px;">
|
||||||
<h2 class="card-title">Preview</h2>
|
<h2 class="card-title">Preview</h2>
|
||||||
<p class="sub">How this widget currently renders.</p>
|
<p class="sub">How this widget currently renders.</p>
|
||||||
|
|||||||
@@ -127,6 +127,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{% include "_widget_border_fields.html" %}
|
||||||
|
|
||||||
<section class="card" style="margin-top: 20px;">
|
<section class="card" style="margin-top: 20px;">
|
||||||
<h2 class="card-title">Preview</h2>
|
<h2 class="card-title">Preview</h2>
|
||||||
<p class="sub">How this widget currently renders.</p>
|
<p class="sub">How this widget currently renders.</p>
|
||||||
|
|||||||
@@ -41,6 +41,8 @@
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{% include "_widget_border_fields.html" %}
|
||||||
|
|
||||||
<section class="card" style="margin-top: 20px;">
|
<section class="card" style="margin-top: 20px;">
|
||||||
<h2 class="card-title">Now displaying</h2>
|
<h2 class="card-title">Now displaying</h2>
|
||||||
<div id="current-thumb"><p class="sub">Loading...</p></div>
|
<div id="current-thumb"><p class="sub">Loading...</p></div>
|
||||||
|
|||||||
@@ -36,6 +36,8 @@
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{% include "_widget_border_fields.html" %}
|
||||||
|
|
||||||
<section class="card" style="margin-top: 20px;">
|
<section class="card" style="margin-top: 20px;">
|
||||||
<h2 class="card-title">Preview</h2>
|
<h2 class="card-title">Preview</h2>
|
||||||
<p class="sub">How this widget currently renders.</p>
|
<p class="sub">How this widget currently renders.</p>
|
||||||
|
|||||||
@@ -59,6 +59,8 @@
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{% include "_widget_border_fields.html" %}
|
||||||
|
|
||||||
<section class="card" style="margin-top: 20px;">
|
<section class="card" style="margin-top: 20px;">
|
||||||
<h2 class="card-title">Preview</h2>
|
<h2 class="card-title">Preview</h2>
|
||||||
<p class="sub">How this widget currently renders.</p>
|
<p class="sub">How this widget currently renders.</p>
|
||||||
|
|||||||
@@ -47,6 +47,8 @@
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{% include "_widget_border_fields.html" %}
|
||||||
|
|
||||||
<section class="card" style="margin-top: 20px;">
|
<section class="card" style="margin-top: 20px;">
|
||||||
<h2 class="card-title">Preview</h2>
|
<h2 class="card-title">Preview</h2>
|
||||||
<p class="sub">How this widget currently renders.</p>
|
<p class="sub">How this widget currently renders.</p>
|
||||||
|
|||||||
@@ -73,6 +73,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{% include "_widget_border_fields.html" %}
|
||||||
|
|
||||||
<section class="card" style="margin-top: 20px;">
|
<section class="card" style="margin-top: 20px;">
|
||||||
<h2 class="card-title">Preview</h2>
|
<h2 class="card-title">Preview</h2>
|
||||||
<p class="sub">How this widget currently renders.</p>
|
<p class="sub">How this widget currently renders.</p>
|
||||||
|
|||||||
@@ -50,6 +50,8 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{% include "_widget_border_fields.html" %}
|
||||||
|
|
||||||
<section class="card" style="margin-top: 20px;">
|
<section class="card" style="margin-top: 20px;">
|
||||||
<h2 class="card-title">Preview</h2>
|
<h2 class="card-title">Preview</h2>
|
||||||
<p class="sub">How this widget currently renders.</p>
|
<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_text.js"></script>
|
||||||
<script src="/static/widget_dialog_weather.js"></script>
|
<script src="/static/widget_dialog_weather.js"></script>
|
||||||
<script src="/static/widget_dialog_battery.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/frame_layout.js"></script>
|
||||||
<script src="/static/saved_layouts.js"></script>
|
<script src="/static/saved_layouts.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ def test_expected_columns_exist_on_current_schema():
|
|||||||
assert "battery_widget_configs" in inspector.get_table_names() # migration 25
|
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")}
|
battery_widget_columns = {c["name"] for c in inspector.get_columns("battery_widget_configs")}
|
||||||
assert "mode" in battery_widget_columns
|
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) ---
|
# --- 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