Add a text widget (rich text: bold/italic/underline, per-run color/highlight)
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:
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user