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.
144 lines
5.7 KiB
Python
144 lines
5.7 KiB
Python
"""Battery widget: shows the frame's own last-reported battery level --
|
|
no live upstream to poll, unlike almost every other widget type. The
|
|
content is frame-level state that already exists regardless of this
|
|
widget (frame.battery_percent/battery_as_of, set by routers/device.py's
|
|
frame_battery on every device wake-on-battery report) plus routers.
|
|
common.battery_estimate_s's existing recency-weighted "how much longer"
|
|
estimate (computed there for the Device panel's own history chart) --
|
|
this widget just draws them, it doesn't fetch or compute anything new.
|
|
BatteryWidgetConfig only holds a display mode (compact: icon + percent;
|
|
detailed: also the estimate + last-report age).
|
|
|
|
No button actions -- there's nothing to advance/back/force for a number
|
|
the device itself pushes on every wake."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import time
|
|
|
|
from PIL import Image, ImageDraw
|
|
from sqlalchemy.orm import Session
|
|
|
|
from .. import panel_style
|
|
from ..image_pipeline import _quantize, draw_text, logical_render_size
|
|
from ..models import BatteryWidgetConfig, Frame, Widget
|
|
from ..routers.common import battery_estimate_s
|
|
from ._shared import placeholder_image
|
|
|
|
ACTIONS: dict = {}
|
|
ACTION_LABELS: dict[str, str] = {}
|
|
|
|
|
|
def _draw_icon(draw: ImageDraw.ImageDraw, cx: int, top: int, icon_w: int, icon_h: int, percent: int,
|
|
palette_rgb: list | None = None) -> None:
|
|
"""Centers panel_style.draw_battery_icon (top-left-anchored) under
|
|
`cx` -- this widget's own layout picks a center point, that helper's
|
|
shared implementation (also used by manage_overlay.py's battery
|
|
readout) just needs a top-left corner."""
|
|
nub_w = max(3, icon_w // 10)
|
|
x0 = cx - (icon_w + nub_w) // 2
|
|
panel_style.draw_battery_icon(draw, x0, top, icon_w, icon_h, percent, palette_rgb)
|
|
|
|
|
|
def _format_estimate(seconds: float) -> str:
|
|
days = seconds / 86400
|
|
if days >= 2:
|
|
return f"~{days:.0f}d left"
|
|
hours = seconds / 3600
|
|
if hours >= 20:
|
|
return "~1d left"
|
|
return f"~{max(1, round(hours))}h left"
|
|
|
|
|
|
def _format_age(as_of: float) -> str:
|
|
delta = max(0.0, time.time() - as_of)
|
|
if delta < 3600:
|
|
return f"{max(1, round(delta / 60))}m ago"
|
|
if delta < 86400:
|
|
return f"{round(delta / 3600)}h ago"
|
|
return f"{round(delta / 86400)}d ago"
|
|
|
|
|
|
def _lines_for(mode: str, frame: Frame, db: Session) -> list[str]:
|
|
"""The 0-2 caption lines "detailed" mode shows below the percent --
|
|
shared by both render styles so the estimate/age formatting only
|
|
lives in one place."""
|
|
if mode != "detailed":
|
|
return []
|
|
lines = []
|
|
estimate_s = battery_estimate_s(frame, db)
|
|
if estimate_s is not None:
|
|
lines.append(_format_estimate(estimate_s))
|
|
if frame.battery_as_of:
|
|
lines.append(f"Reported {_format_age(frame.battery_as_of)}")
|
|
return lines
|
|
|
|
|
|
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."""
|
|
percent = frame.battery_percent
|
|
if percent < 0:
|
|
return placeholder_image(target_w, target_h, ["Battery", "No reports yet"])
|
|
|
|
cfg = db.get(BatteryWidgetConfig, widget.id)
|
|
mode = cfg.mode if cfg else "detailed"
|
|
palette_rgb = frame.palette_rgb
|
|
lines = _lines_for(mode, frame, db)
|
|
|
|
if cfg and 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 (same reasoning as
|
|
# image_pipeline.render_placeholder's local `import qrcode`).
|
|
from .. import html_render
|
|
|
|
return html_render.build_battery(percent, lines, target_w, target_h, palette_rgb, frame.theme)
|
|
|
|
img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h)
|
|
cx = cx0 + cw // 2
|
|
|
|
icon_h = max(20, min(cw, ch) // 3)
|
|
icon_w = int(icon_h * 1.8)
|
|
icon_top = max(4, cy0 + ch // 8)
|
|
_draw_icon(draw, cx, icon_top, icon_w, icon_h, percent, palette_rgb)
|
|
|
|
# The percent number picks up the icon's own charge-level color
|
|
# (red/yellow/green) instead of plain black -- ties the two into one
|
|
# visual statement rather than "colored icon, black number".
|
|
pct_font_size = max(18, min(cw, ch) // 3)
|
|
pct_font = panel_style.font_bold(pct_font_size)
|
|
pct_text = f"{percent}%"
|
|
bbox = draw.textbbox((0, 0), pct_text, font=pct_font)
|
|
pct_y = icon_top + icon_h + 10
|
|
draw_text(img, (cx - (bbox[2] - bbox[0]) // 2, pct_y), pct_text, pct_font,
|
|
panel_style.battery_fill_color(percent, palette_rgb))
|
|
|
|
small_font_size = max(11, pct_font_size // 3)
|
|
small_font = panel_style.font_regular(small_font_size)
|
|
y = pct_y + pct_font_size + 12
|
|
for line in lines:
|
|
if y + small_font_size > cy0 + ch - 4:
|
|
break
|
|
lbbox = draw.textbbox((0, 0), line, font=small_font)
|
|
draw_text(img, (cx - (lbbox[2] - lbbox[0]) // 2, y), line, small_font)
|
|
y += small_font_size + 6
|
|
|
|
return img
|
|
|
|
|
|
def render_preview_png(db: Session, frame: Frame, widget: Widget, orientation: str,
|
|
palette_rgb: list | None) -> bytes:
|
|
"""A normal browser-viewable PNG at full logical panel size -- same
|
|
"dialog preview always renders at the frame's full size, not the
|
|
widget's actual grid box" convention as text.py's render_preview_png."""
|
|
target_w, target_h = logical_render_size(orientation)
|
|
img = render(db, frame, widget, 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()
|