Introduces app/panel_style.py, a shared style module every render module now draws through instead of independently duplicating margins/ colors/fonts: Inter Bold/Regular (already vendored, previously only used by widgets/text.py) replace PIL's single-weight bundled default font everywhere else; a per-widget-kind accent color (calendar=blue, tasks=green, weather=black header) replaces plain black-on-white chrome and is centralized in one THEME mapping so a future global theme only needs to touch panel_style.py; a small per-widget gutter separates adjacent widgets without touching grid.py's cell math; header bars, color chips, and the battery icon get rounded corners. Also drops the MUTED gray text color used throughout calendar_render.py and weather_render.py -- a non-palette color that has no close match in the panel's 6-ink palette and dithers into visible speckle once the composited canvas is quantized. Secondary text now reads through size/ weight alone, always exact black. widgets/battery.py and manage_overlay.py's previously-duplicated battery-glyph-drawing code now share one implementation (panel_style. draw_battery_icon). widgets/_shared.py's placeholder image is fixed to use exact palette colors and route through image_pipeline.draw_text, same as everything else -- it was quietly violating both rules already. image_pipeline.draw_widget_border gains an opt-in radius param (default 0, unused by any call site) for a possible future rounded-border setting -- doesn't touch the exact-corner-pixel behavior test_widget_ border.py already pins. Deliberately out of scope: DEFAULT_PALETTE_RGB and the Floyd-Steinberg quantization pipeline are untouched, per the prior reverted measured- palette/OKLab attempt (05b417a/dfe9d701).
127 lines
5.0 KiB
Python
127 lines
5.0 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 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
|
|
|
|
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))
|
|
|
|
if mode == "detailed":
|
|
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)}")
|
|
|
|
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()
|