Frame.theme (7 presets in app/theme_tokens.py) drives font family, corner radius, drop shadow, and an accent hue for every modern-style widget's header/accent region. Rich accent colors (not just the 6 flat panel inks) are approximated via denser Bayer stippling confined to just that region (html_render.ordered_dither_regions), so icon/text content elsewhere stays exactly as crisp as it is today -- verified directly against real Chromium renders, both in unit tests and via run-server. "classic" is a byte-identical no-visual-change default: weather's header keeps its original fixed blue gradient, tasks/calendar keep their flat THEME_* ink. Themes are purely stylistic -- battery's charge-level color, calendar/ tasks' per-owner event chips, and text's own per-widget font choice are never touched.
229 lines
10 KiB
Python
229 lines
10 KiB
Python
"""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/*.ttf, OFL-
|
|
licensed like the emoji fonts already there) across a small curated set
|
|
of families (FONT_FAMILIES) rather than every other widget's single
|
|
ImageFont.load_default() -- the one widget type where that distinction
|
|
is the whole point. Font family is a whole-widget setting like
|
|
font_size/align, not per-run -- only bold/italic/underline/color/bg
|
|
vary run-to-run (see app/text_content.py).
|
|
|
|
No button actions -- there's nothing to advance/back/check for a fixed
|
|
block of authored text."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
|
|
from PIL import Image, ImageDraw
|
|
from sqlalchemy.orm import Session
|
|
|
|
from .. import theme_tokens
|
|
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)
|
|
|
|
# The font-family table (a small curated set, not an open-ended picker --
|
|
# each entry needs a real vendored Regular/Bold/Italic/BoldItalic file)
|
|
# lives in app/theme_tokens.py now, shared with every modern-style
|
|
# widget's own font resolution -- re-exported here under their original
|
|
# names since this was the text widget's own table before the theme
|
|
# system needed it too (see app/fonts/OFL-*.txt for each non-Noto
|
|
# family's own license/copyright).
|
|
DEFAULT_FONT_FAMILY = theme_tokens.DEFAULT_FONT_FAMILY
|
|
FONT_FAMILIES = theme_tokens.FONT_FAMILIES
|
|
_FONT_FILES = theme_tokens._FONT_FILES
|
|
_font = theme_tokens.font
|
|
_WORD_OR_SPACE = re.compile(r"\S+|\s+")
|
|
|
|
|
|
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], family: str, size: int) -> float:
|
|
return sum(draw.textlength(tok["text"], font=_font(family, tok["bold"], tok["italic"], size))
|
|
for tok in group)
|
|
|
|
|
|
def _wrap_paragraph(draw: ImageDraw.ImageDraw, groups: list[list[dict]], family: str, 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, family, 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]], family: str, 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(family, False, False, size))
|
|
lines = []
|
|
for paragraph in paragraphs:
|
|
lines.extend(_wrap_paragraph(draw, _paragraph_word_groups(paragraph), family, 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,
|
|
family: str, size: int, line_h: int, max_width: int, align: str, space_width: float) -> None:
|
|
line_width = sum(_group_width(draw, g, family, 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(family, 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)
|
|
|
|
family = cfg.font_family if cfg.font_family in FONT_FAMILIES else DEFAULT_FONT_FAMILY
|
|
max_width = max(10, target_w - 2 * MARGIN)
|
|
max_height = max(10, target_h - 2 * MARGIN)
|
|
size, lines = _fit(draw, cfg.content or [], family, cfg.font_size, max_width, max_height)
|
|
line_h = round(size * LINE_HEIGHT_FACTOR)
|
|
space_width = draw.textlength(" ", font=_font(family, 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, family, size, line_h, max_width, align, space_width)
|
|
y += line_h
|
|
return img
|
|
|
|
|
|
def _render_dispatch(cfg: TextWidgetConfig, target_w: int, target_h: int,
|
|
palette_rgb: list | None, theme_name: str | None = None) -> Image.Image:
|
|
"""classic vs modern (app/html_render.py) -- shared by render() and
|
|
render_preview_png() so both honor render_style identically (weather
|
|
once shipped with its preview endpoint bypassing render_style
|
|
entirely by calling the classic renderer directly -- this shared
|
|
dispatch point exists specifically so that bug can't happen here).
|
|
palette_rgb is unused by the classic path (it never quantizes itself
|
|
-- see module docstring), only threaded through for modern's own
|
|
ordered_dither. theme_name is threaded through uniformly (every
|
|
modern-style widget's dispatch takes one) but build_text ignores it
|
|
-- see its own docstring for why (the text widget's font is a
|
|
per-widget, user-authored choice, not theme-driven)."""
|
|
if cfg.render_style == "modern":
|
|
# Local import: html_render pulls in Playwright, a real headless-
|
|
# Chromium dependency -- every other widget type, and this one's
|
|
# own classic path, should never pay for it.
|
|
from .. import html_render
|
|
|
|
return html_render.build_text(cfg, target_w, target_h, palette_rgb, theme_name)
|
|
return _render_text(cfg, target_w, target_h)
|
|
|
|
|
|
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_dispatch(cfg, target_w, target_h, frame.palette_rgb, frame.theme)
|
|
|
|
|
|
def render_preview_png(cfg: TextWidgetConfig, orientation: str, palette_rgb: list | None,
|
|
theme_name: str | None = 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_dispatch(cfg, target_w, target_h, palette_rgb, theme_name)
|
|
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
|
|
buf = io.BytesIO()
|
|
quantized.convert("RGB").save(buf, format="PNG")
|
|
return buf.getvalue()
|