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:
+14
-9
@@ -1,8 +1,8 @@
|
|||||||
# Widget system
|
# Widget system
|
||||||
|
|
||||||
A frame's panel isn't one fixed "mode" anymore -- it holds N independently
|
A frame's panel isn't one fixed "mode" anymore -- it holds N independently
|
||||||
placed/sized widgets (photos/calendar/whiteboard/tasks/static image), like arranging
|
placed/sized widgets (photos/calendar/whiteboard/tasks/static image/text), like
|
||||||
icons on an Android home screen. A frame can hold several widgets of the
|
arranging icons on an Android home screen. A frame can hold several widgets of the
|
||||||
same type (e.g. two photo widgets pointed at different Immich albums side
|
same type (e.g. two photo widgets pointed at different Immich albums side
|
||||||
by side).
|
by side).
|
||||||
This replaced an earlier design where `Frame.mode` picked exactly one
|
This replaced an earlier design where `Frame.mode` picked exactly one
|
||||||
@@ -20,16 +20,20 @@ a button press does.
|
|||||||
## Data model
|
## Data model
|
||||||
|
|
||||||
- `Widget` (`server/app/models.py`): `id`, `frame_id`, `widget_type`
|
- `Widget` (`server/app/models.py`): `id`, `frame_id`, `widget_type`
|
||||||
(`"photos"` | `"calendar"` | `"whiteboard"` | `"tasks"` | `"static"`), `x`/`y`/`w`/`h`
|
(`"photos"` | `"calendar"` | `"whiteboard"` | `"tasks"` | `"static"` | `"text"`), `x`/`y`/`w`/`h`
|
||||||
(grid cells), `sort_order`. Widgets never overlap (enforced server-side in
|
(grid cells), `sort_order`. Widgets never overlap (enforced server-side in
|
||||||
`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.
|
||||||
- Per-type 1:1 extension tables -- `PhotoWidgetConfig`,
|
- Per-type 1:1 extension tables -- `PhotoWidgetConfig`,
|
||||||
`CalendarWidgetConfig`, `WhiteboardWidgetConfig`, `TaskWidgetConfig`,
|
`CalendarWidgetConfig`, `WhiteboardWidgetConfig`, `TaskWidgetConfig`,
|
||||||
(plus `StaticWidgetConfig`) each keyed by `widget_id` with
|
`StaticWidgetConfig`, `TextWidgetConfig`, each keyed by `widget_id` with
|
||||||
`ondelete="CASCADE"` -- rather than one wide table with every type's
|
`ondelete="CASCADE"` -- rather than one wide table with every type's
|
||||||
mostly-irrelevant columns. `PhotoWidgetConfig`
|
mostly-irrelevant columns. `TextWidgetConfig.content` is parsed rich
|
||||||
|
text (paragraphs of styled runs), never raw HTML -- see
|
||||||
|
`server/app/text_content.py`'s module docstring for why that parse
|
||||||
|
step is the widget's actual stored-XSS sanitization boundary.
|
||||||
|
`PhotoWidgetConfig`
|
||||||
mirrors `app/photo_queue.py`'s attribute names exactly, so that module's
|
mirrors `app/photo_queue.py`'s attribute names exactly, so that module's
|
||||||
advance/back/queue logic ports across widget instances unchanged.
|
advance/back/queue logic ports across widget instances unchanged.
|
||||||
`TaskWidgetConfig` used to be a handful of `tasks_*` columns bolted onto
|
`TaskWidgetConfig` used to be a handful of `tasks_*` columns bolted onto
|
||||||
@@ -61,7 +65,7 @@ orientation change rather than trying to remap coordinates.
|
|||||||
|
|
||||||
Each widget type has a minimum grid footprint (`grid.MIN_FOOTPRINT`):
|
Each widget type has a minimum grid footprint (`grid.MIN_FOOTPRINT`):
|
||||||
photos 1x1, calendar 3x2 (a crammed calendar is illegible regardless of
|
photos 1x1, calendar 3x2 (a crammed calendar is illegible regardless of
|
||||||
size-tier scaling), whiteboard 2x2, tasks 2x2, static image 1x1.
|
size-tier scaling), whiteboard 2x2, tasks 2x2, static image 1x1, text 2x1.
|
||||||
Enforced both client-side
|
Enforced both client-side
|
||||||
(UX, in the Layout tab's drag/resize canvas -- `static/frame_layout.js`)
|
(UX, in the Layout tab's drag/resize canvas -- `static/frame_layout.js`)
|
||||||
and server-side (`routers/api_widgets.py`) -- the client is never trusted
|
and server-side (`routers/api_widgets.py`) -- the client is never trusted
|
||||||
@@ -71,7 +75,7 @@ alone.
|
|||||||
|
|
||||||
`app/widgets/` is the render/action registry -- one module per
|
`app/widgets/` is the render/action registry -- one module per
|
||||||
`widget_type` (`photos.py`, `calendar.py`, `whiteboard.py`, `tasks.py`,
|
`widget_type` (`photos.py`, `calendar.py`, `whiteboard.py`, `tasks.py`,
|
||||||
`static_image.py`), each exposing:
|
`static_image.py`, `text.py`), each exposing:
|
||||||
|
|
||||||
- `render(db, frame, widget, target_w, target_h, is_normal_wake) -> Image`:
|
- `render(db, frame, widget, target_w, target_h, is_normal_wake) -> Image`:
|
||||||
an unquantized RGB image exactly `target_w x target_h`, the widget's
|
an unquantized RGB image exactly `target_w x target_h`, the widget's
|
||||||
@@ -81,8 +85,9 @@ alone.
|
|||||||
bad moment doesn't blank the whole panel.
|
bad moment doesn't blank the whole panel.
|
||||||
- `ACTIONS: dict[str, Callable]` -- named button actions this type
|
- `ACTIONS: dict[str, Callable]` -- named button actions this type
|
||||||
supports (`"advance"`/`"back"` for photos and calendar, `"check_now"`
|
supports (`"advance"`/`"back"` for photos and calendar, `"check_now"`
|
||||||
for whiteboard). Empty for tasks and static image -- nothing to
|
for whiteboard). Empty for tasks, static image, and text -- nothing to
|
||||||
advance/back/force for a passive checklist or a fixed uploaded image.
|
advance/back/force for a passive checklist, a fixed uploaded image, or
|
||||||
|
a fixed block of authored text.
|
||||||
- `ACTION_LABELS: dict[str, str]` -- human labels for the button-
|
- `ACTION_LABELS: dict[str, str]` -- human labels for the button-
|
||||||
assignment UI.
|
assignment UI.
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -31,6 +31,7 @@ MIN_FOOTPRINT: dict[str, tuple[int, int]] = {
|
|||||||
"whiteboard": (2, 2),
|
"whiteboard": (2, 2),
|
||||||
"tasks": (2, 2),
|
"tasks": (2, 2),
|
||||||
"static": (1, 1),
|
"static": (1, 1),
|
||||||
|
"text": (2, 1),
|
||||||
}
|
}
|
||||||
|
|
||||||
Rect = tuple[int, int, int, int] # (x, y, w, h)
|
Rect = tuple[int, int, int, int] # (x, y, w, h)
|
||||||
|
|||||||
@@ -511,6 +511,14 @@ def _migration_20(conn) -> None:
|
|||||||
Base.metadata.create_all(bind=conn)
|
Base.metadata.create_all(bind=conn)
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_21(conn) -> None:
|
||||||
|
"""New widget type: a text widget shows user-authored rich text (see
|
||||||
|
app/text_content.py, app/widgets/text.py, models.TextWidgetConfig)
|
||||||
|
-- another no-live-upstream type like migration 20's static image.
|
||||||
|
Same brand-new-table create_all shape."""
|
||||||
|
Base.metadata.create_all(bind=conn)
|
||||||
|
|
||||||
|
|
||||||
MIGRATIONS = [
|
MIGRATIONS = [
|
||||||
(1, _migration_1),
|
(1, _migration_1),
|
||||||
(2, _migration_2),
|
(2, _migration_2),
|
||||||
@@ -532,6 +540,7 @@ MIGRATIONS = [
|
|||||||
(18, _migration_18),
|
(18, _migration_18),
|
||||||
(19, _migration_19),
|
(19, _migration_19),
|
||||||
(20, _migration_20),
|
(20, _migration_20),
|
||||||
|
(21, _migration_21),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -553,6 +553,37 @@ class WhiteboardWidgetConfig(Base):
|
|||||||
cached_image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
|
cached_image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class TextWidgetConfig(Base):
|
||||||
|
"""One text widget's authored content + display settings -- another
|
||||||
|
no-live-upstream type like StaticWidgetConfig, just parsed rich text
|
||||||
|
instead of an uploaded image. content is never raw HTML: the
|
||||||
|
dialog's contenteditable innerHTML is parsed server-side (see
|
||||||
|
app/text_content.py, the sanitization boundary) into this plain
|
||||||
|
run structure at save time, so render() (app/widgets/text.py) never
|
||||||
|
re-parses/sanitizes HTML on every panel refresh, and the dialog never
|
||||||
|
re-injects stored HTML via innerHTML when reopened.
|
||||||
|
|
||||||
|
[[{"text","bold","italic","underline","color","bg"}, ...], ...] --
|
||||||
|
outer list is paragraphs (line breaks), inner list is styled runs
|
||||||
|
within that paragraph. color/bg are "#rrggbb" or null (falls back to
|
||||||
|
black text / no highlight). NULL (not just []) means never
|
||||||
|
configured, matching StaticWidgetConfig.image's None-vs-empty
|
||||||
|
convention for "not configured yet"."""
|
||||||
|
|
||||||
|
__tablename__ = "text_widget_configs"
|
||||||
|
|
||||||
|
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True)
|
||||||
|
content: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
|
||||||
|
# Base point size for the whole block -- render() shrinks this down
|
||||||
|
# (never up) to fit the widget's actual box; per-run font size isn't
|
||||||
|
# supported, only the bold/italic/underline/color/bg style flags are
|
||||||
|
# per-run (see app/text_content.py) -- keeps the wrap/shrink-to-fit
|
||||||
|
# layout in app/widgets/text.py to one size per render pass.
|
||||||
|
font_size: Mapped[int] = mapped_column(Integer, default=28)
|
||||||
|
align: Mapped[str] = mapped_column(String, default="left") # "left" | "center" | "right"
|
||||||
|
background_color: Mapped[str] = mapped_column(String, default="#ffffff")
|
||||||
|
|
||||||
|
|
||||||
class StaticWidgetConfig(Base):
|
class StaticWidgetConfig(Base):
|
||||||
"""One static-image widget's uploaded content + display settings --
|
"""One static-image widget's uploaded content + display settings --
|
||||||
unlike every other widget type, this one has no live upstream to
|
unlike every other widget type, this one has no live upstream to
|
||||||
@@ -583,6 +614,7 @@ WIDGET_CONFIG_MODELS: dict[str, type] = {
|
|||||||
"whiteboard": WhiteboardWidgetConfig,
|
"whiteboard": WhiteboardWidgetConfig,
|
||||||
"tasks": TaskWidgetConfig,
|
"tasks": TaskWidgetConfig,
|
||||||
"static": StaticWidgetConfig,
|
"static": StaticWidgetConfig,
|
||||||
|
"text": TextWidgetConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ from ..image_pipeline import (
|
|||||||
DEFAULT_DISPLAY_MODE,
|
DEFAULT_DISPLAY_MODE,
|
||||||
DEFAULT_STATIC_DISPLAY_MODE,
|
DEFAULT_STATIC_DISPLAY_MODE,
|
||||||
DISPLAY_MODES,
|
DISPLAY_MODES,
|
||||||
|
hex_to_rgb,
|
||||||
STATIC_DISPLAY_MODES,
|
STATIC_DISPLAY_MODES,
|
||||||
render_preview_png,
|
render_preview_png,
|
||||||
)
|
)
|
||||||
@@ -45,11 +46,14 @@ from ..models import (
|
|||||||
PhotoWidgetConfig,
|
PhotoWidgetConfig,
|
||||||
StaticWidgetConfig,
|
StaticWidgetConfig,
|
||||||
TaskWidgetConfig,
|
TaskWidgetConfig,
|
||||||
|
TextWidgetConfig,
|
||||||
WhiteboardWidgetConfig,
|
WhiteboardWidgetConfig,
|
||||||
WIDGET_CONFIG_MODELS,
|
WIDGET_CONFIG_MODELS,
|
||||||
Widget,
|
Widget,
|
||||||
)
|
)
|
||||||
|
from ..text_content import has_text, parse_rich_text
|
||||||
from ..widgets import WIDGET_TYPES
|
from ..widgets import WIDGET_TYPES
|
||||||
|
from ..widgets import text as text_widget
|
||||||
from .common import (
|
from .common import (
|
||||||
calendar_sources_for_widget,
|
calendar_sources_for_widget,
|
||||||
fetch_source_and_faces,
|
fetch_source_and_faces,
|
||||||
@@ -69,6 +73,8 @@ router = APIRouter()
|
|||||||
|
|
||||||
MIN_QUEUE_TARGET_LEN = 5
|
MIN_QUEUE_TARGET_LEN = 5
|
||||||
MAX_QUEUE_TARGET_LEN = 5000
|
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
|
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
|
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
|
||||||
tasks_name: str | None = Form(None),
|
tasks_name: str | None = Form(None),
|
||||||
tasks_show_completed: bool | 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
|
"""Every field optional -- same partial-update, form-urlencoded
|
||||||
convention as the old frame-level api_config_save, now scoped to one
|
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):
|
with widget_locked(db, frame.id, widget.id) as (_, _, scfg):
|
||||||
if display_mode is not None:
|
if display_mode is not None:
|
||||||
scfg.display_mode = display_mode if display_mode in STATIC_DISPLAY_MODES else DEFAULT_STATIC_DISPLAY_MODE
|
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:
|
with frame_locked(db, frame.id) as cfg:
|
||||||
cfg.stats_config_saves += 1
|
cfg.stats_config_saves += 1
|
||||||
return {"status": "saved"}
|
return {"status": "saved"}
|
||||||
@@ -852,6 +878,25 @@ def api_widget_preview_static(
|
|||||||
return Response(content=png, media_type="image/png")
|
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 ------------------------------------------
|
# --- Whiteboard: source/preview ------------------------------------------
|
||||||
|
|
||||||
class WhiteboardSourceRequest(BaseModel):
|
class WhiteboardSourceRequest(BaseModel):
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ from ..models import (
|
|||||||
PhotoWidgetConfig,
|
PhotoWidgetConfig,
|
||||||
StaticWidgetConfig,
|
StaticWidgetConfig,
|
||||||
TaskWidgetConfig,
|
TaskWidgetConfig,
|
||||||
|
TextWidgetConfig,
|
||||||
User,
|
User,
|
||||||
UserFrame,
|
UserFrame,
|
||||||
WhiteboardWidgetConfig,
|
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},
|
"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":
|
if widget.widget_type == "whiteboard":
|
||||||
whiteboard_cfg = db.get(WhiteboardWidgetConfig, widget.id)
|
whiteboard_cfg = db.get(WhiteboardWidgetConfig, widget.id)
|
||||||
viewer_has_webdav_creds = bool(
|
viewer_has_webdav_creds = bool(
|
||||||
|
|||||||
@@ -62,7 +62,7 @@
|
|||||||
// UI (Layout canvas, Add-a-widget buttons, button-assignment dropdowns).
|
// UI (Layout canvas, Add-a-widget buttons, button-assignment dropdowns).
|
||||||
const WIDGET_LABELS = {
|
const WIDGET_LABELS = {
|
||||||
photos: 'Photos', calendar: 'Calendar', whiteboard: 'Whiteboard (alpha)', tasks: 'Tasks',
|
photos: 'Photos', calendar: 'Calendar', whiteboard: 'Whiteboard (alpha)', tasks: 'Tasks',
|
||||||
static: 'Static image',
|
static: 'Static image', text: 'Text',
|
||||||
};
|
};
|
||||||
|
|
||||||
function showStatus(ok, message) {
|
function showStatus(ok, message) {
|
||||||
|
|||||||
@@ -286,11 +286,11 @@ window.addEventListener('resize', () => {
|
|||||||
// icon was clicked).
|
// icon was clicked).
|
||||||
const DIALOG_INIT = {
|
const DIALOG_INIT = {
|
||||||
photos: initPhotosDialog, calendar: initCalendarDialog, whiteboard: initWhiteboardDialog,
|
photos: initPhotosDialog, calendar: initCalendarDialog, whiteboard: initWhiteboardDialog,
|
||||||
tasks: initTasksDialog, static: initStaticDialog,
|
tasks: initTasksDialog, static: initStaticDialog, text: initTextDialog,
|
||||||
};
|
};
|
||||||
const DIALOG_CLOSE = {
|
const DIALOG_CLOSE = {
|
||||||
photos: closePhotosDialog, calendar: closeCalendarDialog, whiteboard: closeWhiteboardDialog,
|
photos: closePhotosDialog, calendar: closeCalendarDialog, whiteboard: closeWhiteboardDialog,
|
||||||
tasks: closeTasksDialog, static: closeStaticDialog,
|
tasks: closeTasksDialog, static: closeStaticDialog, text: closeTextDialog,
|
||||||
};
|
};
|
||||||
|
|
||||||
let openDialogWidgetType = null;
|
let openDialogWidgetType = null;
|
||||||
|
|||||||
@@ -205,6 +205,12 @@ details.card .sub { margin-top: 8px; }
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
input[type="color"] {
|
||||||
|
width: 44px;
|
||||||
|
height: 34px;
|
||||||
|
padding: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
input[type="range"] {
|
input[type="range"] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
@@ -281,6 +287,49 @@ input:focus, select:focus {
|
|||||||
.checkbox-row input { width: auto; margin-top: 0; }
|
.checkbox-row input { width: auto; margin-top: 0; }
|
||||||
.checkbox-row label { margin-top: 0; font-weight: normal; }
|
.checkbox-row label { margin-top: 0; font-weight: normal; }
|
||||||
|
|
||||||
|
.richtext-toolbar { display: flex; align-items: center; gap: 4px; margin-top: 12px; flex-wrap: wrap; }
|
||||||
|
.richtext-btn {
|
||||||
|
width: auto;
|
||||||
|
min-width: 32px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--surface-alt);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.richtext-btn:hover { background: var(--surface); }
|
||||||
|
.richtext-toolbar-sep { width: 1px; align-self: stretch; background: var(--border); margin: 0 4px; }
|
||||||
|
.richtext-color-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin: 0;
|
||||||
|
font-weight: normal;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.richtext-color-label input[type="color"] {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.richtext-editor {
|
||||||
|
margin-top: 8px;
|
||||||
|
min-height: 90px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface-alt);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.richtext-editor:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
|
||||||
|
|
||||||
.calendar-user-list { list-style: none; margin: 12px 0 0; padding: 0; }
|
.calendar-user-list { list-style: none; margin: 12px 0 0; padding: 0; }
|
||||||
.calendar-user-list > li { margin-top: 14px; }
|
.calendar-user-list > li { margin-top: 14px; }
|
||||||
.calendar-user-list > li:first-child { margin-top: 0; }
|
.calendar-user-list > li:first-child { margin-top: 0; }
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
// Text widget dialog: a small rich-text editor (bold/italic/underline,
|
||||||
|
// text/highlight color), font size/alignment/background settings, and
|
||||||
|
// the rendered preview. Not a page-load script -- frame_layout.js
|
||||||
|
// fetches this widget's dialog HTML fragment, injects it into the
|
||||||
|
// shared <dialog>, points window.FRAME_API at this specific widget
|
||||||
|
// (/api/frames/{id}/widgets/{widget_id}), then calls initTextDialog().
|
||||||
|
//
|
||||||
|
// The editor is never seeded via innerHTML string interpolation --
|
||||||
|
// #text-editor's data-content attribute (server-rendered from
|
||||||
|
// app/text_content.py's already-sanitized run structure, not raw HTML)
|
||||||
|
// is JSON-parsed and rebuilt with createElement/textContent below. The
|
||||||
|
// same <div><span style="..."> shape this produces is exactly what
|
||||||
|
// app/text_content.py's parser expects back on save, so round-tripping
|
||||||
|
// (load -> edit -> save -> reload) is stable.
|
||||||
|
|
||||||
|
function loadTextPreview() {
|
||||||
|
document.getElementById('text-preview').src = `${window.FRAME_API}/preview/text?_=${Date.now()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTextEditorContent(editor, paragraphs) {
|
||||||
|
editor.textContent = '';
|
||||||
|
if (!paragraphs || !paragraphs.length) return;
|
||||||
|
paragraphs.forEach((para) => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
if (!para.length) {
|
||||||
|
div.appendChild(document.createElement('br'));
|
||||||
|
} else {
|
||||||
|
para.forEach((run) => {
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.textContent = run.text;
|
||||||
|
if (run.bold) span.style.fontWeight = 'bold';
|
||||||
|
if (run.italic) span.style.fontStyle = 'italic';
|
||||||
|
if (run.underline) span.style.textDecoration = 'underline';
|
||||||
|
if (run.color) span.style.color = run.color;
|
||||||
|
if (run.bg) span.style.backgroundColor = run.bg;
|
||||||
|
div.appendChild(span);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
editor.appendChild(div);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let _textSavedRange = null;
|
||||||
|
let _textSelectionHandler = null;
|
||||||
|
|
||||||
|
function initTextDialog() {
|
||||||
|
const editor = document.getElementById('text-editor');
|
||||||
|
let initialContent = null;
|
||||||
|
try {
|
||||||
|
initialContent = JSON.parse(editor.dataset.content || 'null');
|
||||||
|
} catch (e) { /* leave empty */ }
|
||||||
|
buildTextEditorContent(editor, initialContent);
|
||||||
|
|
||||||
|
// Native <input type="color"> steals focus (and with it, the
|
||||||
|
// editor's text selection) the moment it's interacted with -- track
|
||||||
|
// the most recent in-editor selection continuously so a color pick
|
||||||
|
// can be reapplied to the text the user actually had selected,
|
||||||
|
// instead of applying to nothing.
|
||||||
|
_textSelectionHandler = () => {
|
||||||
|
const sel = window.getSelection();
|
||||||
|
if (sel.rangeCount > 0 && editor.contains(sel.anchorNode)) {
|
||||||
|
_textSavedRange = sel.getRangeAt(0).cloneRange();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('selectionchange', _textSelectionHandler);
|
||||||
|
|
||||||
|
function restoreSelection() {
|
||||||
|
if (!_textSavedRange) return;
|
||||||
|
const sel = window.getSelection();
|
||||||
|
sel.removeAllRanges();
|
||||||
|
sel.addRange(_textSavedRange);
|
||||||
|
}
|
||||||
|
|
||||||
|
['text-bold', 'text-italic', 'text-underline'].forEach((id) => {
|
||||||
|
const btn = document.getElementById(id);
|
||||||
|
// preventDefault on mousedown keeps focus (and the selection) in
|
||||||
|
// the editor, so the click's execCommand has something to act on.
|
||||||
|
btn.addEventListener('mousedown', (e) => e.preventDefault());
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
editor.focus();
|
||||||
|
document.execCommand(btn.dataset.cmd, false, null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('text-color').addEventListener('input', (e) => {
|
||||||
|
editor.focus();
|
||||||
|
restoreSelection();
|
||||||
|
document.execCommand('foreColor', false, e.target.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('text-highlight').addEventListener('input', (e) => {
|
||||||
|
editor.focus();
|
||||||
|
restoreSelection();
|
||||||
|
document.execCommand('hiliteColor', false, e.target.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('text-highlight-clear').addEventListener('mousedown', (e) => e.preventDefault());
|
||||||
|
document.getElementById('text-highlight-clear').addEventListener('click', () => {
|
||||||
|
editor.focus();
|
||||||
|
restoreSelection();
|
||||||
|
document.execCommand('hiliteColor', false, 'transparent');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('text_font_size').addEventListener('input', (e) => {
|
||||||
|
document.getElementById('text_font_size_value').textContent = `${e.target.value}px`;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('text-config-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
text_html: editor.innerHTML,
|
||||||
|
text_font_size: document.getElementById('text_font_size').value,
|
||||||
|
text_align: document.getElementById('text_align').value,
|
||||||
|
text_background_color: document.getElementById('text_background_color').value,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${window.FRAME_API}/config`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(await apiError(resp));
|
||||||
|
showStatus(true, 'Saved.');
|
||||||
|
loadTextPreview();
|
||||||
|
} catch (e2) {
|
||||||
|
showStatus(false, e2.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('text-preview-refresh').addEventListener('click', loadTextPreview);
|
||||||
|
loadTextPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeTextDialog() {
|
||||||
|
if (_textSelectionHandler) {
|
||||||
|
document.removeEventListener('selectionchange', _textSelectionHandler);
|
||||||
|
_textSelectionHandler = null;
|
||||||
|
}
|
||||||
|
_textSavedRange = null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<h2 class="dialog-title">Text widget</h2>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2 class="card-title">Text</h2>
|
||||||
|
<p class="sub">A short block of styled text -- select some and use the
|
||||||
|
toolbar, or just type. Saved as plain text plus style flags, not raw
|
||||||
|
HTML.</p>
|
||||||
|
<div class="richtext-toolbar" id="text-toolbar">
|
||||||
|
<button type="button" class="richtext-btn" id="text-bold" title="Bold" data-cmd="bold"><b>B</b></button>
|
||||||
|
<button type="button" class="richtext-btn" id="text-italic" title="Italic" data-cmd="italic"><i>I</i></button>
|
||||||
|
<button type="button" class="richtext-btn" id="text-underline" title="Underline" data-cmd="underline"><u>U</u></button>
|
||||||
|
<span class="richtext-toolbar-sep"></span>
|
||||||
|
<label class="richtext-color-label" title="Text color">A<input type="color" id="text-color" value="#000000"></label>
|
||||||
|
<label class="richtext-color-label" title="Highlight color">▣<input type="color" id="text-highlight" value="#ffff00"></label>
|
||||||
|
<button type="button" class="richtext-btn" id="text-highlight-clear" title="Remove highlight">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="richtext-editor" id="text-editor" contenteditable="true"
|
||||||
|
data-content='{{ (text_cfg.content if text_cfg else none) | tojson }}'></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card" style="margin-top: 20px;">
|
||||||
|
<h2 class="card-title">Settings</h2>
|
||||||
|
<form id="text-config-form">
|
||||||
|
<label>Font size
|
||||||
|
<input type="range" id="text_font_size" min="10" max="96" step="2"
|
||||||
|
value="{{ text_cfg.font_size if text_cfg else 28 }}">
|
||||||
|
<span class="slider-value" id="text_font_size_value">{{ text_cfg.font_size if text_cfg else 28 }}px</span>
|
||||||
|
</label>
|
||||||
|
<label>Alignment
|
||||||
|
<select id="text_align">
|
||||||
|
{% for value, label in [("left", "Left"), ("center", "Center"), ("right", "Right")] %}
|
||||||
|
<option value="{{ value }}" {% if text_cfg and text_cfg.align == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Background color
|
||||||
|
<input type="color" id="text_background_color" value="{{ text_cfg.background_color if text_cfg else '#ffffff' }}">
|
||||||
|
</label>
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card" style="margin-top: 20px;">
|
||||||
|
<h2 class="card-title">Preview</h2>
|
||||||
|
<p class="sub">How this widget currently renders.</p>
|
||||||
|
<img class="preview-img" id="text-preview" alt="Text widget preview">
|
||||||
|
<button type="button" class="secondary" id="text-preview-refresh">Refresh now</button>
|
||||||
|
</section>
|
||||||
@@ -60,5 +60,6 @@
|
|||||||
<script src="/static/widget_dialog_whiteboard.js"></script>
|
<script src="/static/widget_dialog_whiteboard.js"></script>
|
||||||
<script src="/static/widget_dialog_tasks.js"></script>
|
<script src="/static/widget_dialog_tasks.js"></script>
|
||||||
<script src="/static/widget_dialog_static.js"></script>
|
<script src="/static/widget_dialog_static.js"></script>
|
||||||
|
<script src="/static/widget_dialog_text.js"></script>
|
||||||
<script src="/static/frame_layout.js"></script>
|
<script src="/static/frame_layout.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"""Parses a contenteditable div's serialized innerHTML (widget_dialog_
|
||||||
|
text.js's POSTed content_html) into a plain, storage-safe run structure
|
||||||
|
-- list of paragraphs, each a list of {"text", "bold", "italic",
|
||||||
|
"underline", "color", "bg"} runs -- for the text widget (see
|
||||||
|
models.TextWidgetConfig, app/widgets/text.py).
|
||||||
|
|
||||||
|
This is the sanitization boundary the checklist's stored-XSS note
|
||||||
|
(CLAUDE.md, another linked user could have set this) is about: raw HTML
|
||||||
|
never round-trips back into any browser DOM. Only text content and a
|
||||||
|
small fixed set of style flags survive parsing; every tag, attribute,
|
||||||
|
and CSS property not explicitly recognized below is simply discarded --
|
||||||
|
there's no allowlist-of-tags-to-keep-as-HTML step where something could
|
||||||
|
slip through unescaped, because nothing is ever re-emitted as HTML at
|
||||||
|
all. The dialog reconstructs its editor from this same run structure via
|
||||||
|
safe DOM calls (createElement/textContent), never innerHTML."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from html.parser import HTMLParser
|
||||||
|
|
||||||
|
# Generous ceilings, not exact UX limits -- just stop a direct API call
|
||||||
|
# (bypassing the dialog's own textarea-ish size) from storing something
|
||||||
|
# pathologically large. MAX_INPUT_CHARS bounds parse work; MAX_TOTAL_CHARS
|
||||||
|
# bounds what's actually kept (a widget's on-panel region is a few
|
||||||
|
# hundred pixels -- there is no legible use for more than a few thousand
|
||||||
|
# characters of body text there).
|
||||||
|
MAX_INPUT_CHARS = 200_000
|
||||||
|
MAX_TOTAL_CHARS = 4_000
|
||||||
|
|
||||||
|
_BASE_STYLE = {"bold": False, "italic": False, "underline": False, "color": None, "bg": None}
|
||||||
|
_BLOCK_TAGS = {"div", "p", "li"}
|
||||||
|
_VOID_TAGS = {"br"}
|
||||||
|
|
||||||
|
_HEX6 = re.compile(r"^#([0-9a-fA-F]{6})$")
|
||||||
|
_HEX3 = re.compile(r"^#([0-9a-fA-F]{3})$")
|
||||||
|
_RGB = re.compile(r"^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(?:,\s*[\d.]+\s*)?\)$")
|
||||||
|
_STYLE_PROP = re.compile(r"([a-zA-Z-]+)\s*:\s*([^;]+)")
|
||||||
|
_BOLD_WEIGHTS = {"bold", "bolder", "600", "700", "800", "900"}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_color(value: str) -> str | None:
|
||||||
|
""""#1a2b3c" / "#abc" / "rgb(26, 43, 60)" -> "#1a2b3c". Anything else
|
||||||
|
(a CSS named color, "transparent", garbage) -> None, i.e. dropped --
|
||||||
|
this is the one place an arbitrary style-attribute string could try
|
||||||
|
to smuggle something through, so it's a strict allowlist match, not
|
||||||
|
a best-effort parse."""
|
||||||
|
value = value.strip()
|
||||||
|
m = _HEX6.match(value)
|
||||||
|
if m:
|
||||||
|
return "#" + m.group(1).lower()
|
||||||
|
m = _HEX3.match(value)
|
||||||
|
if m:
|
||||||
|
return "#" + "".join(c * 2 for c in m.group(1)).lower()
|
||||||
|
m = _RGB.match(value)
|
||||||
|
if m:
|
||||||
|
r, g, b = (max(0, min(255, int(x))) for x in m.groups())
|
||||||
|
return f"#{r:02x}{g:02x}{b:02x}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class _RichTextParser(HTMLParser):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__(convert_charrefs=True)
|
||||||
|
self.paragraphs: list[list[dict]] = [[]]
|
||||||
|
self._style_stack: list[dict] = [_BASE_STYLE]
|
||||||
|
self._at_line_start = True
|
||||||
|
|
||||||
|
def _break(self, tag: str) -> None:
|
||||||
|
# Coalesces contenteditable's per-line block wrapping (Chrome
|
||||||
|
# wraps every line in its own <div> even without a deliberate
|
||||||
|
# blank line) down to one paragraph break per actual line gap,
|
||||||
|
# while still letting an explicit <br> when already at a fresh
|
||||||
|
# line start (Chrome's "<div><br></div>" idiom for a blank line,
|
||||||
|
# or a genuine double Shift+Enter) add a real blank paragraph.
|
||||||
|
if not self._at_line_start:
|
||||||
|
self.paragraphs.append([])
|
||||||
|
self._at_line_start = True
|
||||||
|
elif tag == "br":
|
||||||
|
self.paragraphs.append([])
|
||||||
|
|
||||||
|
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||||
|
if tag in _BLOCK_TAGS or tag in _VOID_TAGS:
|
||||||
|
self._break(tag)
|
||||||
|
if tag in _VOID_TAGS:
|
||||||
|
return
|
||||||
|
style = dict(self._style_stack[-1])
|
||||||
|
attrs_dict = {k: v for k, v in attrs if v is not None}
|
||||||
|
if tag in ("b", "strong"):
|
||||||
|
style["bold"] = True
|
||||||
|
elif tag in ("i", "em"):
|
||||||
|
style["italic"] = True
|
||||||
|
elif tag == "u":
|
||||||
|
style["underline"] = True
|
||||||
|
elif tag == "font":
|
||||||
|
color = _normalize_color(attrs_dict.get("color", ""))
|
||||||
|
if color:
|
||||||
|
style["color"] = color
|
||||||
|
elif tag == "span":
|
||||||
|
for prop, val in _STYLE_PROP.findall(attrs_dict.get("style", "")):
|
||||||
|
prop = prop.strip().lower()
|
||||||
|
val = val.strip()
|
||||||
|
if prop == "color":
|
||||||
|
color = _normalize_color(val)
|
||||||
|
if color:
|
||||||
|
style["color"] = color
|
||||||
|
elif prop == "background-color":
|
||||||
|
color = _normalize_color(val)
|
||||||
|
if color:
|
||||||
|
style["bg"] = color
|
||||||
|
elif prop == "font-weight" and val.lower() in _BOLD_WEIGHTS:
|
||||||
|
style["bold"] = True
|
||||||
|
elif prop == "font-style" and val.lower() == "italic":
|
||||||
|
style["italic"] = True
|
||||||
|
elif prop == "text-decoration" and "underline" in val.lower():
|
||||||
|
style["underline"] = True
|
||||||
|
# Pushed for every non-void tag, including ones with no
|
||||||
|
# recognized style effect (script/a/img/...) -- keeps push/pop
|
||||||
|
# balanced against handle_endtag regardless of tag, without
|
||||||
|
# needing to track which tags actually pushed something.
|
||||||
|
self._style_stack.append(style)
|
||||||
|
|
||||||
|
def handle_endtag(self, tag: str) -> None:
|
||||||
|
if tag in _VOID_TAGS:
|
||||||
|
return
|
||||||
|
if len(self._style_stack) > 1:
|
||||||
|
self._style_stack.pop()
|
||||||
|
|
||||||
|
def handle_data(self, data: str) -> None:
|
||||||
|
if not data:
|
||||||
|
return
|
||||||
|
style = self._style_stack[-1]
|
||||||
|
self.paragraphs[-1].append({"text": data, **style})
|
||||||
|
if data.strip():
|
||||||
|
self._at_line_start = False
|
||||||
|
|
||||||
|
|
||||||
|
def parse_rich_text(html: str) -> list[list[dict]]:
|
||||||
|
"""The sanitization entry point -- see module docstring. Always
|
||||||
|
returns a valid (possibly all-empty) paragraphs structure, never
|
||||||
|
raises for malformed markup (html.parser tolerates unclosed/
|
||||||
|
mismatched tags; handle_endtag's length guard tolerates an
|
||||||
|
over-popped stack)."""
|
||||||
|
parser = _RichTextParser()
|
||||||
|
parser.feed(html[:MAX_INPUT_CHARS])
|
||||||
|
parser.close()
|
||||||
|
paragraphs = parser.paragraphs
|
||||||
|
|
||||||
|
total = 0
|
||||||
|
truncated: list[list[dict]] = []
|
||||||
|
for para in paragraphs:
|
||||||
|
new_para: list[dict] = []
|
||||||
|
for run in para:
|
||||||
|
remaining = MAX_TOTAL_CHARS - total
|
||||||
|
if remaining <= 0:
|
||||||
|
break
|
||||||
|
text = run["text"][:remaining]
|
||||||
|
total += len(text)
|
||||||
|
new_para.append({**run, "text": text})
|
||||||
|
truncated.append(new_para)
|
||||||
|
if total >= MAX_TOTAL_CHARS:
|
||||||
|
break
|
||||||
|
return truncated
|
||||||
|
|
||||||
|
|
||||||
|
def has_text(paragraphs: list[list[dict]] | None) -> bool:
|
||||||
|
if not paragraphs:
|
||||||
|
return False
|
||||||
|
return any(run["text"].strip() for para in paragraphs for run in para)
|
||||||
@@ -36,7 +36,7 @@ Each module in this package exposes:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from . import calendar, photos, static_image, tasks, whiteboard
|
from . import calendar, photos, static_image, tasks, text, whiteboard
|
||||||
|
|
||||||
WIDGET_TYPES = {
|
WIDGET_TYPES = {
|
||||||
"photos": photos,
|
"photos": photos,
|
||||||
@@ -44,4 +44,5 @@ WIDGET_TYPES = {
|
|||||||
"whiteboard": whiteboard,
|
"whiteboard": whiteboard,
|
||||||
"tasks": tasks,
|
"tasks": tasks,
|
||||||
"static": static_image,
|
"static": static_image,
|
||||||
|
"text": text,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
"""Text widget: user-authored rich text (bold/italic/underline, per-run
|
||||||
|
text/highlight color), composed once in the dialog and rendered on
|
||||||
|
every panel refresh from the parsed run structure -- no live upstream to
|
||||||
|
fetch, same self-contained shape as static_image.py, just word-wrapped
|
||||||
|
text instead of an uploaded image. See app/text_content.py for how the
|
||||||
|
dialog's contenteditable HTML becomes models.TextWidgetConfig.content
|
||||||
|
(the sanitization boundary; this module never sees raw HTML).
|
||||||
|
|
||||||
|
Bold/italic use real vendored font weights (app/fonts/NotoSans-*.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 is the whole point.
|
||||||
|
|
||||||
|
No button actions -- there's nothing to advance/back/check for a fixed
|
||||||
|
block of authored text."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..image_pipeline import _quantize, draw_text, hex_to_rgb, logical_render_size
|
||||||
|
from ..models import Frame, TextWidgetConfig, Widget
|
||||||
|
from ..text_content import has_text
|
||||||
|
from ._shared import placeholder_image
|
||||||
|
|
||||||
|
ACTIONS: dict = {}
|
||||||
|
ACTION_LABELS: dict[str, str] = {}
|
||||||
|
|
||||||
|
MARGIN = 14
|
||||||
|
MIN_FONT_SIZE = 10
|
||||||
|
LINE_HEIGHT_FACTOR = 1.35
|
||||||
|
DEFAULT_FG = (0, 0, 0)
|
||||||
|
DEFAULT_BG = (255, 255, 255)
|
||||||
|
|
||||||
|
_FONT_DIR = Path(__file__).resolve().parent.parent / "fonts"
|
||||||
|
_FONT_FILES = {
|
||||||
|
(False, False): "NotoSans-Regular.ttf",
|
||||||
|
(True, False): "NotoSans-Bold.ttf",
|
||||||
|
(False, True): "NotoSans-Italic.ttf",
|
||||||
|
(True, True): "NotoSans-BoldItalic.ttf",
|
||||||
|
}
|
||||||
|
_WORD_OR_SPACE = re.compile(r"\S+|\s+")
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=128)
|
||||||
|
def _font(bold: bool, italic: bool, size: int) -> ImageFont.FreeTypeFont:
|
||||||
|
return ImageFont.truetype(str(_FONT_DIR / _FONT_FILES[(bold, italic)]), size)
|
||||||
|
|
||||||
|
|
||||||
|
def _paragraph_word_groups(paragraph: list[dict]) -> list[list[dict]]:
|
||||||
|
"""One paragraph's styled runs -> word groups: each group is a list
|
||||||
|
of same-word sub-tokens that must stay glued together on one line
|
||||||
|
(no whitespace between them in the source) -- otherwise bolding part
|
||||||
|
of a word (e.g. "wor**ld**") would introduce a spurious space at the
|
||||||
|
style boundary once wrapped. Whitespace runs become the implicit gap
|
||||||
|
between groups (collapsed to a single space, however many source
|
||||||
|
characters it was)."""
|
||||||
|
groups: list[list[dict]] = []
|
||||||
|
current: list[dict] = []
|
||||||
|
for run in paragraph:
|
||||||
|
for piece in _WORD_OR_SPACE.findall(run["text"]):
|
||||||
|
if piece.isspace():
|
||||||
|
if current:
|
||||||
|
groups.append(current)
|
||||||
|
current = []
|
||||||
|
else:
|
||||||
|
current.append({**run, "text": piece})
|
||||||
|
if current:
|
||||||
|
groups.append(current)
|
||||||
|
return groups
|
||||||
|
|
||||||
|
|
||||||
|
def _group_width(draw: ImageDraw.ImageDraw, group: list[dict], size: int) -> float:
|
||||||
|
return sum(draw.textlength(tok["text"], font=_font(tok["bold"], tok["italic"], size)) for tok in group)
|
||||||
|
|
||||||
|
|
||||||
|
def _wrap_paragraph(draw: ImageDraw.ImageDraw, groups: list[list[dict]], size: int,
|
||||||
|
max_width: int, space_width: float) -> list[list[list[dict]]]:
|
||||||
|
"""Greedy word wrap -> list of lines, each a list of word groups.
|
||||||
|
An empty `groups` (a blank authored line) still produces one empty
|
||||||
|
line, to preserve the blank line's vertical space."""
|
||||||
|
lines: list[list[list[dict]]] = []
|
||||||
|
current: list[list[dict]] = []
|
||||||
|
current_w = 0.0
|
||||||
|
for group in groups:
|
||||||
|
gw = _group_width(draw, group, size)
|
||||||
|
add_w = gw + (space_width if current else 0)
|
||||||
|
if current and current_w + add_w > max_width:
|
||||||
|
lines.append(current)
|
||||||
|
current = [group]
|
||||||
|
current_w = gw
|
||||||
|
else:
|
||||||
|
current.append(group)
|
||||||
|
current_w += add_w
|
||||||
|
if current or not groups:
|
||||||
|
lines.append(current)
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def _fit(draw: ImageDraw.ImageDraw, paragraphs: list[list[dict]], start_size: int,
|
||||||
|
max_width: int, max_height: int) -> tuple[int, list[list[list[dict]]]]:
|
||||||
|
"""Shrinks font size (down to MIN_FONT_SIZE) until the wrapped
|
||||||
|
content's total height fits max_height, or gives up at the floor --
|
||||||
|
a too-small widget box just clips rather than raising. Returns the
|
||||||
|
chosen size and the flat list of lines (each a list of word groups)
|
||||||
|
across every paragraph, in order."""
|
||||||
|
size = max(MIN_FONT_SIZE, start_size)
|
||||||
|
lines: list[list[list[dict]]] = []
|
||||||
|
while True:
|
||||||
|
space_width = draw.textlength(" ", font=_font(False, False, size))
|
||||||
|
lines = []
|
||||||
|
for paragraph in paragraphs:
|
||||||
|
lines.extend(_wrap_paragraph(draw, _paragraph_word_groups(paragraph), size, max_width, space_width))
|
||||||
|
line_h = round(size * LINE_HEIGHT_FACTOR)
|
||||||
|
total_h = len(lines) * line_h
|
||||||
|
if total_h <= max_height or size <= MIN_FONT_SIZE:
|
||||||
|
return size, lines
|
||||||
|
size = max(MIN_FONT_SIZE, size - 2)
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_line(img: Image.Image, draw: ImageDraw.ImageDraw, line: list[list[dict]], y: int,
|
||||||
|
size: int, line_h: int, max_width: int, align: str, space_width: float) -> None:
|
||||||
|
line_width = sum(_group_width(draw, g, size) for g in line) + space_width * max(0, len(line) - 1)
|
||||||
|
if align == "center":
|
||||||
|
x = MARGIN + max(0, (max_width - line_width) / 2)
|
||||||
|
elif align == "right":
|
||||||
|
x = MARGIN + max(0, max_width - line_width)
|
||||||
|
else:
|
||||||
|
x = MARGIN
|
||||||
|
underline_h = max(1, size // 16)
|
||||||
|
for gi, group in enumerate(line):
|
||||||
|
for tok in group:
|
||||||
|
font = _font(tok["bold"], tok["italic"], size)
|
||||||
|
w = draw.textlength(tok["text"], font=font)
|
||||||
|
if tok["bg"]:
|
||||||
|
bg_rgb = hex_to_rgb(tok["bg"])
|
||||||
|
if bg_rgb:
|
||||||
|
draw.rectangle([x, y, x + w, y + line_h], fill=bg_rgb)
|
||||||
|
fill = hex_to_rgb(tok["color"]) if tok["color"] else None
|
||||||
|
draw_text(img, (round(x), y), tok["text"], font, fill or DEFAULT_FG)
|
||||||
|
if tok["underline"]:
|
||||||
|
underline_y = y + font.size + 1
|
||||||
|
draw.rectangle([x, underline_y, x + w, underline_y + underline_h], fill=fill or DEFAULT_FG)
|
||||||
|
x += w
|
||||||
|
if gi < len(line) - 1:
|
||||||
|
x += space_width
|
||||||
|
|
||||||
|
|
||||||
|
def _render_text(cfg: TextWidgetConfig, target_w: int, target_h: int) -> Image.Image:
|
||||||
|
bg = hex_to_rgb(cfg.background_color) or DEFAULT_BG
|
||||||
|
img = Image.new("RGB", (target_w, target_h), bg)
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
max_width = max(10, target_w - 2 * MARGIN)
|
||||||
|
max_height = max(10, target_h - 2 * MARGIN)
|
||||||
|
size, lines = _fit(draw, cfg.content or [], cfg.font_size, max_width, max_height)
|
||||||
|
line_h = round(size * LINE_HEIGHT_FACTOR)
|
||||||
|
space_width = draw.textlength(" ", font=_font(False, False, size))
|
||||||
|
|
||||||
|
total_h = len(lines) * line_h
|
||||||
|
y = MARGIN + max(0, (max_height - total_h) // 2)
|
||||||
|
align = cfg.align if cfg.align in ("left", "center", "right") else "left"
|
||||||
|
for line in lines:
|
||||||
|
if y + line_h > target_h:
|
||||||
|
break # ran out of room even at the smallest size -- clip remaining lines rather than overflow
|
||||||
|
_draw_line(img, draw, line, y, size, line_h, max_width, align, space_width)
|
||||||
|
y += line_h
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: int,
|
||||||
|
is_normal_wake: bool = True) -> Image.Image:
|
||||||
|
"""is_normal_wake is unused -- see app/widgets/whiteboard.py's
|
||||||
|
identical note; every widget type's render() shares one call
|
||||||
|
signature regardless of which ones actually care."""
|
||||||
|
cfg = db.get(TextWidgetConfig, widget.id)
|
||||||
|
if cfg is None or not has_text(cfg.content):
|
||||||
|
return placeholder_image(target_w, target_h, ["Text widget", "not configured yet"])
|
||||||
|
return _render_text(cfg, target_w, target_h)
|
||||||
|
|
||||||
|
|
||||||
|
def render_preview_png(cfg: TextWidgetConfig, orientation: str, palette_rgb: list | None) -> bytes:
|
||||||
|
"""A normal browser-viewable PNG at full logical panel size --
|
||||||
|
mirrors calendar_render.render_tasks_preview_png's relationship to
|
||||||
|
render_tasks (the dialog's own preview endpoint always renders at
|
||||||
|
the frame's full size, not the widget's actual grid box, same
|
||||||
|
convention every other widget type's preview endpoint follows)."""
|
||||||
|
import io
|
||||||
|
|
||||||
|
target_w, target_h = logical_render_size(orientation)
|
||||||
|
img = _render_text(cfg, target_w, target_h)
|
||||||
|
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
quantized.convert("RGB").save(buf, format="PNG")
|
||||||
|
return buf.getvalue()
|
||||||
@@ -76,6 +76,7 @@ def test_expected_columns_exist_on_current_schema():
|
|||||||
assert "calendar_week_start_offset" in frame_columns
|
assert "calendar_week_start_offset" in frame_columns
|
||||||
assert "name" in task_widget_columns # migration 19
|
assert "name" in task_widget_columns # migration 19
|
||||||
assert "static_widget_configs" in inspector.get_table_names() # migration 20
|
assert "static_widget_configs" in inspector.get_table_names() # migration 20
|
||||||
|
assert "text_widget_configs" in inspector.get_table_names() # migration 21
|
||||||
|
|
||||||
|
|
||||||
# --- widget system backfill (migration 16 + _ensure_widgets_backfilled) ---
|
# --- widget system backfill (migration 16 + _ensure_widgets_backfilled) ---
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
"""app.text_content.parse_rich_text -- pure parsing logic, no HTTP, no
|
||||||
|
DB. This is the sanitization boundary for the text widget's dialog
|
||||||
|
(widget_dialog_text.js posts contenteditable innerHTML here, see
|
||||||
|
routers/api_widgets.py's api_widget_config_save "text" branch); the
|
||||||
|
main thing under test is that only recognized style flags survive and
|
||||||
|
everything else (unknown tags/attributes, unparseable colors, excess
|
||||||
|
length) is silently dropped rather than round-tripped."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.text_content import MAX_TOTAL_CHARS, has_text, parse_rich_text
|
||||||
|
|
||||||
|
|
||||||
|
def test_plain_text_is_one_paragraph_one_run():
|
||||||
|
paragraphs = parse_rich_text("Hello world")
|
||||||
|
assert paragraphs == [[{"text": "Hello world", "bold": False, "italic": False,
|
||||||
|
"underline": False, "color": None, "bg": None}]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_bold_italic_underline_tags():
|
||||||
|
paragraphs = parse_rich_text("<b>bold</b> <i>italic</i> <u>under</u>")
|
||||||
|
runs = paragraphs[0]
|
||||||
|
assert runs[0]["bold"] is True and runs[0]["text"] == "bold"
|
||||||
|
assert runs[2]["italic"] is True and runs[2]["text"] == "italic"
|
||||||
|
assert runs[4]["underline"] is True and runs[4]["text"] == "under"
|
||||||
|
|
||||||
|
|
||||||
|
def test_strong_and_em_are_treated_like_b_and_i():
|
||||||
|
paragraphs = parse_rich_text("<strong>bold</strong><em>italic</em>")
|
||||||
|
runs = paragraphs[0]
|
||||||
|
assert runs[0]["bold"] is True
|
||||||
|
assert runs[1]["italic"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_span_style_color_and_background():
|
||||||
|
html = '<span style="color: rgb(207, 0, 15); background-color: #ffdb00;">hi</span>'
|
||||||
|
run = parse_rich_text(html)[0][0]
|
||||||
|
assert run["color"] == "#cf000f"
|
||||||
|
assert run["bg"] == "#ffdb00"
|
||||||
|
|
||||||
|
|
||||||
|
def test_span_style_font_weight_and_style_and_decoration():
|
||||||
|
html = '<span style="font-weight: bold; font-style: italic; text-decoration: underline;">x</span>'
|
||||||
|
run = parse_rich_text(html)[0][0]
|
||||||
|
assert run["bold"] is True
|
||||||
|
assert run["italic"] is True
|
||||||
|
assert run["underline"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_font_tag_color_attribute():
|
||||||
|
run = parse_rich_text('<font color="#00ff00">green</font>')[0][0]
|
||||||
|
assert run["color"] == "#00ff00"
|
||||||
|
|
||||||
|
|
||||||
|
def test_short_hex_color_expands():
|
||||||
|
run = parse_rich_text('<span style="color: #f00;">red</span>')[0][0]
|
||||||
|
assert run["color"] == "#ff0000"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unparseable_color_is_dropped():
|
||||||
|
run = parse_rich_text('<span style="color: papayawhip;">x</span>')[0][0]
|
||||||
|
assert run["color"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_nested_styles_combine():
|
||||||
|
run = parse_rich_text("<b><i>both</i></b>")[0][0]
|
||||||
|
assert run["bold"] is True
|
||||||
|
assert run["italic"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_style_does_not_leak_past_closing_tag():
|
||||||
|
paragraphs = parse_rich_text("<b>bold</b>plain")
|
||||||
|
runs = paragraphs[0]
|
||||||
|
assert runs[0]["bold"] is True
|
||||||
|
assert runs[1]["bold"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_div_per_line_becomes_separate_paragraphs():
|
||||||
|
paragraphs = parse_rich_text("<div>line one</div><div>line two</div>")
|
||||||
|
assert [p[0]["text"] for p in paragraphs] == ["line one", "line two"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_shift_enter_br_within_a_div_also_breaks_paragraphs():
|
||||||
|
paragraphs = parse_rich_text("<div>line one<br>line two</div>")
|
||||||
|
assert [p[0]["text"] for p in paragraphs] == ["line one", "line two"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_blank_line_idiom_produces_one_empty_paragraph():
|
||||||
|
html = "<div>A</div><div><br></div><div>B</div>"
|
||||||
|
paragraphs = parse_rich_text(html)
|
||||||
|
assert [p[0]["text"] if p else None for p in paragraphs] == ["A", None, "B"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_double_blank_line_produces_two_empty_paragraphs():
|
||||||
|
html = "<div>A</div><div><br></div><div><br></div><div>B</div>"
|
||||||
|
paragraphs = parse_rich_text(html)
|
||||||
|
assert [p[0]["text"] if p else None for p in paragraphs] == ["A", None, None, "B"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_mid_word_style_change_does_not_insert_a_space():
|
||||||
|
""""wor" bolded, "ld" not -- must still read as one word "world" when
|
||||||
|
rendered (see app/widgets/text.py's word-grouping), not "wor ld"."""
|
||||||
|
paragraphs = parse_rich_text("<b>wor</b>ld")
|
||||||
|
runs = paragraphs[0]
|
||||||
|
assert [r["text"] for r in runs] == ["wor", "ld"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_unrecognized_tags_are_dropped_but_their_text_survives_as_plain():
|
||||||
|
"""A <script> (or any tag outside the recognized set) never executes
|
||||||
|
or persists as a tag -- its text content just becomes an ordinary
|
||||||
|
unstyled run, exactly like any other stray text."""
|
||||||
|
paragraphs = parse_rich_text('<script>alert(1)</script>hello')
|
||||||
|
runs = paragraphs[0]
|
||||||
|
assert any(r["text"] == "alert(1)" and not r["bold"] for r in runs)
|
||||||
|
assert any(r["text"] == "hello" for r in runs)
|
||||||
|
|
||||||
|
|
||||||
|
def test_style_attribute_cannot_smuggle_unrecognized_css():
|
||||||
|
"""Only color/background-color/font-weight/font-style/text-decoration
|
||||||
|
are ever read from a style attribute -- anything else (a CSS
|
||||||
|
injection attempt via e.g. a bogus property) is just ignored."""
|
||||||
|
html = '<span style="position: fixed; top: 0; color: #123456;">x</span>'
|
||||||
|
run = parse_rich_text(html)[0][0]
|
||||||
|
assert run["color"] == "#123456"
|
||||||
|
# No other keys were introduced by the extra property.
|
||||||
|
assert set(run.keys()) == {"text", "bold", "italic", "underline", "color", "bg"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_content_is_truncated_to_max_total_chars():
|
||||||
|
html = "a" * (MAX_TOTAL_CHARS + 500)
|
||||||
|
paragraphs = parse_rich_text(html)
|
||||||
|
total = sum(len(r["text"]) for p in paragraphs for r in p)
|
||||||
|
assert total == MAX_TOTAL_CHARS
|
||||||
|
|
||||||
|
|
||||||
|
def test_malformed_html_does_not_raise():
|
||||||
|
parse_rich_text("<b><i>unclosed tags <div>and a stray </b>")
|
||||||
|
|
||||||
|
|
||||||
|
def test_has_text_false_for_none_and_blank():
|
||||||
|
assert has_text(None) is False
|
||||||
|
assert has_text([]) is False
|
||||||
|
assert has_text([[]]) is False
|
||||||
|
assert has_text([[{"text": " ", "bold": False, "italic": False,
|
||||||
|
"underline": False, "color": None, "bg": None}]]) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_has_text_true_for_real_content():
|
||||||
|
assert has_text(parse_rich_text("hi")) is True
|
||||||
@@ -19,6 +19,7 @@ from app.models import (
|
|||||||
PhotoWidgetConfig,
|
PhotoWidgetConfig,
|
||||||
StaticWidgetConfig,
|
StaticWidgetConfig,
|
||||||
TaskWidgetConfig,
|
TaskWidgetConfig,
|
||||||
|
TextWidgetConfig,
|
||||||
Widget,
|
Widget,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -67,6 +68,18 @@ def _add_static_widget(db_session) -> Widget:
|
|||||||
return widget
|
return widget
|
||||||
|
|
||||||
|
|
||||||
|
def _add_text_widget(db_session) -> Widget:
|
||||||
|
import time
|
||||||
|
|
||||||
|
widget = Widget(frame_id=1, widget_type="text", x=0, y=0, w=2, h=1,
|
||||||
|
sort_order=1, created_at=time.time())
|
||||||
|
db_session.add(widget)
|
||||||
|
db_session.flush()
|
||||||
|
db_session.add(TextWidgetConfig(widget_id=widget.id))
|
||||||
|
db_session.commit()
|
||||||
|
return widget
|
||||||
|
|
||||||
|
|
||||||
def _png_bytes(size=(20, 10), color=(10, 20, 30)) -> bytes:
|
def _png_bytes(size=(20, 10), color=(10, 20, 30)) -> bytes:
|
||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
Image.new("RGB", size, color).save(buf, format="PNG")
|
Image.new("RGB", size, color).save(buf, format="PNG")
|
||||||
@@ -184,6 +197,49 @@ def test_config_save_rejects_an_unrecognized_static_display_mode(client, db_sess
|
|||||||
assert cfg.display_mode == "crop_fill"
|
assert cfg.display_mode == "crop_fill"
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_save_updates_a_text_widget(client, db_session):
|
||||||
|
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||||
|
widget = _add_text_widget(db_session)
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/frames/1/widgets/{widget.id}/config",
|
||||||
|
data={
|
||||||
|
"text_html": '<div>Hello <b>world</b></div>',
|
||||||
|
"text_font_size": "40",
|
||||||
|
"text_align": "center",
|
||||||
|
"text_background_color": "#ffdb00",
|
||||||
|
},
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
cfg = db_session.get(TextWidgetConfig, widget.id)
|
||||||
|
assert cfg.content == [[
|
||||||
|
{"text": "Hello ", "bold": False, "italic": False, "underline": False, "color": None, "bg": None},
|
||||||
|
{"text": "world", "bold": True, "italic": False, "underline": False, "color": None, "bg": None},
|
||||||
|
]]
|
||||||
|
assert cfg.font_size == 40
|
||||||
|
assert cfg.align == "center"
|
||||||
|
assert cfg.background_color == "#ffdb00"
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_save_clamps_text_font_size_and_rejects_bad_align_and_color(client, db_session):
|
||||||
|
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||||
|
widget = _add_text_widget(db_session)
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/frames/1/widgets/{widget.id}/config",
|
||||||
|
data={"text_font_size": "500", "text_align": "diagonal", "text_background_color": "not-a-color"},
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
cfg = db_session.get(TextWidgetConfig, widget.id)
|
||||||
|
assert cfg.font_size == 96 # clamped to MAX_TEXT_FONT_SIZE
|
||||||
|
assert cfg.align == "left" # fell back to the default
|
||||||
|
assert cfg.background_color == "#ffffff" # fell back to the default
|
||||||
|
|
||||||
|
|
||||||
def test_config_save_only_partially_updates_provided_fields(client, db_session):
|
def test_config_save_only_partially_updates_provided_fields(client, db_session):
|
||||||
"""Fields not present in the POST are left untouched -- the whole
|
"""Fields not present in the POST are left untouched -- the whole
|
||||||
point of the partial-update convention (each dialog's own form only
|
point of the partial-update convention (each dialog's own form only
|
||||||
@@ -330,3 +386,33 @@ def test_preview_static_renders_after_upload(client, db_session):
|
|||||||
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/static")
|
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/static")
|
||||||
assert resp.status_code == 200, resp.text
|
assert resp.status_code == 200, resp.text
|
||||||
assert resp.headers["content-type"] == "image/png"
|
assert resp.headers["content-type"] == "image/png"
|
||||||
|
|
||||||
|
|
||||||
|
# --- text: preview -----------------------------------------------------
|
||||||
|
|
||||||
|
def test_preview_text_400s_before_anything_is_authored(client, db_session):
|
||||||
|
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||||
|
widget = _add_text_widget(db_session)
|
||||||
|
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/text")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_preview_text_renders_after_saving_content(client, db_session):
|
||||||
|
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||||
|
widget = _add_text_widget(db_session)
|
||||||
|
client.post(
|
||||||
|
f"/api/frames/1/widgets/{widget.id}/config",
|
||||||
|
data={"text_html": "<div>Hello world</div>"},
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/text")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.headers["content-type"] == "image/png"
|
||||||
|
|
||||||
|
|
||||||
|
def test_preview_text_400s_for_a_widget_that_is_not_text(client, db_session):
|
||||||
|
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||||
|
widget = _add_static_widget(db_session)
|
||||||
|
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/text")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"""app.widgets.text -- unit-level, no HTTP: constructs Widget/
|
||||||
|
TextWidgetConfig rows directly with already-parsed run structures (the
|
||||||
|
HTML-parsing step is covered separately in test_text_content.py; the
|
||||||
|
HTTP-level config-save/preview endpoints in
|
||||||
|
test_widget_config_and_queue_endpoints.py). These tests only exercise
|
||||||
|
render()'s own word-wrap/shrink-to-fit/style layout."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
from app import widgets
|
||||||
|
from app.models import Frame, TextWidgetConfig, Widget
|
||||||
|
|
||||||
|
|
||||||
|
def _run(text, **overrides) -> dict:
|
||||||
|
run = {"text": text, "bold": False, "italic": False, "underline": False, "color": None, "bg": None}
|
||||||
|
run.update(overrides)
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
def _make_widget(db_session, **cfg_kwargs) -> tuple[Frame, Widget]:
|
||||||
|
frame = db_session.get(Frame, 1)
|
||||||
|
widget = Widget(frame_id=frame.id, widget_type="text", x=0, y=0, w=2, h=1,
|
||||||
|
sort_order=0, created_at=time.time())
|
||||||
|
db_session.add(widget)
|
||||||
|
db_session.flush()
|
||||||
|
db_session.add(TextWidgetConfig(widget_id=widget.id, **cfg_kwargs))
|
||||||
|
db_session.commit()
|
||||||
|
return frame, widget
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_shows_a_placeholder_when_never_configured(db_session):
|
||||||
|
frame, widget = _make_widget(db_session)
|
||||||
|
img = widgets.text.render(db_session, frame, widget, 300, 200)
|
||||||
|
assert img.size == (300, 200)
|
||||||
|
assert img.mode == "RGB"
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_shows_a_placeholder_for_whitespace_only_content(db_session):
|
||||||
|
frame, widget = _make_widget(db_session, content=[[_run(" ")]])
|
||||||
|
img = widgets.text.render(db_session, frame, widget, 300, 200)
|
||||||
|
assert img.size == (300, 200)
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_draws_configured_text(db_session):
|
||||||
|
frame, widget = _make_widget(db_session, content=[[_run("Hello world")]])
|
||||||
|
img = widgets.text.render(db_session, frame, widget, 300, 200)
|
||||||
|
assert img.size == (300, 200)
|
||||||
|
assert img.mode == "RGB"
|
||||||
|
# Not just a blank/placeholder canvas -- some non-background pixel exists.
|
||||||
|
assert img.getcolors(maxcolors=1) is None or img.getcolors()[0][0] != 300 * 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_respects_background_color(db_session):
|
||||||
|
frame, widget = _make_widget(db_session, content=[[_run("hi")]], background_color="#ff0000")
|
||||||
|
img = widgets.text.render(db_session, frame, widget, 50, 40)
|
||||||
|
assert img.getpixel((0, 0)) == (255, 0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_shrinks_font_to_fit_a_tiny_box(db_session):
|
||||||
|
long_text = " ".join(["word"] * 40)
|
||||||
|
frame, widget = _make_widget(db_session, content=[[_run(long_text)]], font_size=96)
|
||||||
|
# grid.MIN_FOOTPRINT["text"] is (2, 1) cells -- on an 8x5 grid against
|
||||||
|
# a full 800x480 panel that's a 200x96 box, the smallest a text
|
||||||
|
# widget can actually be placed at.
|
||||||
|
img = widgets.text.render(db_session, frame, widget, 200, 96)
|
||||||
|
assert img.size == (200, 96)
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_wraps_across_multiple_paragraphs(db_session):
|
||||||
|
content = [[_run("First paragraph with several words to wrap.")],
|
||||||
|
[_run("Second paragraph, also with text.")]]
|
||||||
|
frame, widget = _make_widget(db_session, content=content)
|
||||||
|
img = widgets.text.render(db_session, frame, widget, 250, 150)
|
||||||
|
assert img.size == (250, 150)
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_applies_bold_italic_underline_color_and_highlight(db_session):
|
||||||
|
content = [[
|
||||||
|
_run("bold", bold=True),
|
||||||
|
_run(" "),
|
||||||
|
_run("italic", italic=True),
|
||||||
|
_run(" "),
|
||||||
|
_run("underline", underline=True),
|
||||||
|
_run(" "),
|
||||||
|
_run("colored", color="#cf000f"),
|
||||||
|
_run(" "),
|
||||||
|
_run("highlighted", bg="#ffdb00"),
|
||||||
|
]]
|
||||||
|
frame, widget = _make_widget(db_session, content=content)
|
||||||
|
img = widgets.text.render(db_session, frame, widget, 400, 150)
|
||||||
|
assert img.size == (400, 150)
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_respects_alignment(db_session):
|
||||||
|
for align in ("left", "center", "right"):
|
||||||
|
frame, widget = _make_widget(db_session, content=[[_run("hi")]], align=align)
|
||||||
|
img = widgets.text.render(db_session, frame, widget, 200, 100)
|
||||||
|
assert img.size == (200, 100)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_button_actions():
|
||||||
|
"""Fixed authored text -- nothing to advance/back/check."""
|
||||||
|
assert widgets.text.ACTIONS == {}
|
||||||
|
assert widgets.text.ACTION_LABELS == {}
|
||||||
Reference in New Issue
Block a user