Modernize on-panel widget visuals: real typography, theme colors, gutter
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).
This commit is contained in:
@@ -7,17 +7,19 @@ fraction of the panel, so its placeholder needs to scale down with it.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
_BG = (245, 245, 245)
|
||||
_FG = (90, 90, 90)
|
||||
from .. import panel_style
|
||||
from ..image_pipeline import draw_text
|
||||
|
||||
_BG = (255, 255, 255)
|
||||
|
||||
|
||||
def placeholder_image(target_w: int, target_h: int, lines: list[str]) -> Image.Image:
|
||||
img = Image.new("RGB", (target_w, target_h), _BG)
|
||||
draw = ImageDraw.Draw(img)
|
||||
font_size = max(10, min(20, target_h // 8))
|
||||
font = ImageFont.load_default(size=font_size)
|
||||
font = panel_style.font_regular(font_size)
|
||||
line_h = font_size + 4
|
||||
total_h = line_h * len(lines)
|
||||
y = max(4, (target_h - total_h) // 2)
|
||||
@@ -25,6 +27,6 @@ def placeholder_image(target_w: int, target_h: int, lines: list[str]) -> Image.I
|
||||
bbox = draw.textbbox((0, 0), line, font=font)
|
||||
line_w = bbox[2] - bbox[0]
|
||||
x = max(4, (target_w - line_w) // 2)
|
||||
draw.text((x, y), line, fill=_FG, font=font)
|
||||
draw_text(img, (x, y), line, font)
|
||||
y += line_h
|
||||
return img
|
||||
|
||||
@@ -17,10 +17,11 @@ from __future__ import annotations
|
||||
import io
|
||||
import time
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from PIL import Image, ImageDraw
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..image_pipeline import DEFAULT_PALETTE_RGB, _quantize, draw_text, logical_render_size
|
||||
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
|
||||
@@ -28,45 +29,16 @@ from ._shared import placeholder_image
|
||||
ACTIONS: dict = {}
|
||||
ACTION_LABELS: dict[str, str] = {}
|
||||
|
||||
BG = (255, 255, 255)
|
||||
MUTED = (110, 110, 110)
|
||||
|
||||
# Same thresholds/colors as manage_overlay.py's own battery glyph (not
|
||||
# shared code -- that one draws onto the manage-QR overlay in a fixed
|
||||
# small size, this one fills an arbitrary widget region -- but the
|
||||
# "how worried should I be" color story should read the same wherever a
|
||||
# battery glyph shows up on a panel). Exact panel ink RGB values, not
|
||||
# arbitrary reds/yellows/greens -- a flat fill already at a palette
|
||||
# color quantizes with zero dithering error once the whole composited
|
||||
# canvas gets quantized, where an off-palette color would dither into a
|
||||
# visible speckle at these small on-panel sizes.
|
||||
_LOW = DEFAULT_PALETTE_RGB[3] # red
|
||||
_MEDIUM = DEFAULT_PALETTE_RGB[2] # yellow
|
||||
_HIGH = DEFAULT_PALETTE_RGB[5] # green
|
||||
|
||||
|
||||
def _fill_color(percent: int) -> tuple[int, int, int]:
|
||||
if percent <= 15:
|
||||
return _LOW
|
||||
if percent <= 40:
|
||||
return _MEDIUM
|
||||
return _HIGH
|
||||
|
||||
|
||||
def _draw_icon(draw: ImageDraw.ImageDraw, cx: int, top: int, icon_w: int, icon_h: int, percent: int) -> None:
|
||||
stroke = max(2, icon_h // 12)
|
||||
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)
|
||||
nub_h = icon_h // 2
|
||||
x0 = cx - (icon_w + nub_w) // 2
|
||||
y0 = top
|
||||
inner_x0, inner_y0 = x0 + stroke, y0 + stroke
|
||||
inner_x1, inner_y1 = x0 + icon_w - stroke, y0 + icon_h - stroke
|
||||
fill_x1 = inner_x0 + round((inner_x1 - inner_x0) * (max(0, min(100, percent)) / 100))
|
||||
if fill_x1 > inner_x0:
|
||||
draw.rectangle([inner_x0, inner_y0, fill_x1, inner_y1], fill=_fill_color(percent))
|
||||
draw.rectangle([x0, y0, x0 + icon_w, y0 + icon_h], outline=(0, 0, 0), width=stroke)
|
||||
nub_y = y0 + (icon_h - nub_h) // 2
|
||||
draw.rectangle([x0 + icon_w, nub_y, x0 + icon_w + nub_w, nub_y + nub_h], fill=(0, 0, 0))
|
||||
panel_style.draw_battery_icon(draw, x0, top, icon_w, icon_h, percent, palette_rgb)
|
||||
|
||||
|
||||
def _format_estimate(seconds: float) -> str:
|
||||
@@ -99,22 +71,26 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i
|
||||
|
||||
cfg = db.get(BatteryWidgetConfig, widget.id)
|
||||
mode = cfg.mode if cfg else "detailed"
|
||||
palette_rgb = frame.palette_rgb
|
||||
|
||||
img = Image.new("RGB", (target_w, target_h), BG)
|
||||
draw = ImageDraw.Draw(img)
|
||||
cx = target_w // 2
|
||||
img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h)
|
||||
cx = cx0 + cw // 2
|
||||
|
||||
icon_h = max(20, min(target_w, target_h) // 3)
|
||||
icon_h = max(20, min(cw, ch) // 3)
|
||||
icon_w = int(icon_h * 1.8)
|
||||
icon_top = max(4, target_h // 8)
|
||||
_draw_icon(draw, cx, icon_top, icon_w, icon_h, percent)
|
||||
icon_top = max(4, cy0 + ch // 8)
|
||||
_draw_icon(draw, cx, icon_top, icon_w, icon_h, percent, palette_rgb)
|
||||
|
||||
pct_font_size = max(18, min(target_w, target_h) // 3)
|
||||
pct_font = ImageFont.load_default(size=pct_font_size)
|
||||
# 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)
|
||||
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 = []
|
||||
@@ -125,13 +101,13 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i
|
||||
lines.append(f"Reported {_format_age(frame.battery_as_of)}")
|
||||
|
||||
small_font_size = max(11, pct_font_size // 3)
|
||||
small_font = ImageFont.load_default(size=small_font_size)
|
||||
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 > target_h - 4:
|
||||
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, MUTED)
|
||||
draw_text(img, (cx - (lbbox[2] - lbbox[0]) // 2, y), line, small_font)
|
||||
y += small_font_size + 6
|
||||
|
||||
return img
|
||||
|
||||
Reference in New Issue
Block a user