Add a text widget (rich text: bold/italic/underline, per-run color/highlight)
Build and push server image / test (push) Successful in 27s
Build and push server image / build-and-push (push) Successful in 1m59s
Build and push server image / deploy (push) Successful in 1m9s

A new self-contained widget type showing user-authored rich text -- no
live upstream to poll, like the static image widget, just word-wrapped
styled text instead of an uploaded image.

The dialog's contenteditable HTML is never stored or replayed as HTML:
app/text_content.py parses it server-side (on save) into a plain
paragraphs-of-styled-runs structure -- the actual sanitization
boundary, since raw HTML never round-trips back into any browser DOM
(the dialog rebuilds its editor from that same JSON via
createElement/textContent). app/widgets/text.py renders it with a
custom word-wrap/shrink-to-fit layout, using real vendored font weights
(app/fonts/NotoSans-{Regular,Bold,Italic,BoldItalic}.ttf, OFL-licensed
like the emoji fonts already there) rather than every other widget's
single ImageFont.load_default() -- the one widget type where that
distinction matters.
This commit is contained in:
Thomas Faour
2026-07-25 14:21:52 +00:00
parent f1fda9bdee
commit 3735c5bfa7
23 changed files with 1062 additions and 13 deletions
+45
View File
@@ -33,6 +33,7 @@ from ..image_pipeline import (
DEFAULT_DISPLAY_MODE,
DEFAULT_STATIC_DISPLAY_MODE,
DISPLAY_MODES,
hex_to_rgb,
STATIC_DISPLAY_MODES,
render_preview_png,
)
@@ -45,11 +46,14 @@ from ..models import (
PhotoWidgetConfig,
StaticWidgetConfig,
TaskWidgetConfig,
TextWidgetConfig,
WhiteboardWidgetConfig,
WIDGET_CONFIG_MODELS,
Widget,
)
from ..text_content import has_text, parse_rich_text
from ..widgets import WIDGET_TYPES
from ..widgets import text as text_widget
from .common import (
calendar_sources_for_widget,
fetch_source_and_faces,
@@ -69,6 +73,8 @@ router = APIRouter()
MIN_QUEUE_TARGET_LEN = 5
MAX_QUEUE_TARGET_LEN = 5000
MIN_TEXT_FONT_SIZE = 10
MAX_TEXT_FONT_SIZE = 96
CALENDAR_COLOR_INDEX_RANGE = range(2, 6) # Yellow/Red/Blue/Green -- see PALETTE_LABELS
MAX_TASKS_NAME_LEN = 40 # a sane on-panel-header length, see calendar_render._draw_tasks
@@ -271,6 +277,11 @@ def api_widget_config_save(
# tasks
tasks_name: str | None = Form(None),
tasks_show_completed: bool | None = Form(None),
# text
text_html: str | None = Form(None),
text_font_size: int | None = Form(None),
text_align: str | None = Form(None),
text_background_color: str | None = Form(None),
):
"""Every field optional -- same partial-update, form-urlencoded
convention as the old frame-level api_config_save, now scoped to one
@@ -347,6 +358,21 @@ def api_widget_config_save(
with widget_locked(db, frame.id, widget.id) as (_, _, scfg):
if display_mode is not None:
scfg.display_mode = display_mode if display_mode in STATIC_DISPLAY_MODES else DEFAULT_STATIC_DISPLAY_MODE
elif widget.widget_type == "text":
with widget_locked(db, frame.id, widget.id) as (_, _, xcfg):
if text_html is not None:
# The one save path that touches app/text_content.py --
# see its module docstring for why parsing (not storing
# raw HTML) is the actual sanitization boundary here.
xcfg.content = parse_rich_text(text_html)
if text_font_size is not None:
xcfg.font_size = max(MIN_TEXT_FONT_SIZE, min(MAX_TEXT_FONT_SIZE, text_font_size))
if text_align is not None:
xcfg.align = text_align if text_align in ("left", "center", "right") else "left"
if text_background_color is not None:
xcfg.background_color = (
text_background_color if hex_to_rgb(text_background_color) else "#ffffff"
)
with frame_locked(db, frame.id) as cfg:
cfg.stats_config_saves += 1
return {"status": "saved"}
@@ -852,6 +878,25 @@ def api_widget_preview_static(
return Response(content=png, media_type="image/png")
# --- Text: preview ----------------------------------------------------------
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/preview/text")
def api_widget_preview_text(
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), db: Session = Depends(get_db)
):
"""The saved rich text run through the same word-wrap/shrink-to-fit
layout and quantize pass a live device render would use -- same
"reflects what's currently saved" convention as the other preview
endpoints."""
frame, widget = frame_widget
_require_widget_type(widget, "text")
xcfg = db.get(TextWidgetConfig, widget.id)
if not has_text(xcfg.content):
raise HTTPException(400, "No text authored on this widget yet")
png = text_widget.render_preview_png(xcfg, orientation=frame.orientation, palette_rgb=frame.palette_rgb)
return Response(content=png, media_type="image/png")
# --- Whiteboard: source/preview ------------------------------------------
class WhiteboardSourceRequest(BaseModel):
+7
View File
@@ -35,6 +35,7 @@ from ..models import (
PhotoWidgetConfig,
StaticWidgetConfig,
TaskWidgetConfig,
TextWidgetConfig,
User,
UserFrame,
WhiteboardWidgetConfig,
@@ -245,6 +246,12 @@ def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session =
"display_mode_labels": {k: v for k, v in DISPLAY_MODE_LABELS.items() if k in STATIC_DISPLAY_MODES},
})
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,
})
if widget.widget_type == "whiteboard":
whiteboard_cfg = db.get(WhiteboardWidgetConfig, widget.id)
viewer_has_webdav_creds = bool(