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:
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user