Files
espresso_frame/server/app/widgets/battery.py
T
tfaour 5f4f8f2ea7
Build and push server image / test (push) Successful in 44s
Build and push server image / build-and-push (push) Successful in 3m33s
Build and push server image / deploy (push) Failing after 1m27s
Add a curated theme system for "modern" style widgets, inspired by Tesserae
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.
2026-07-31 10:34:28 +00:00

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()