Redo modern-style widgets in a "bold minimal" language, not just a reskin
The first modern-style rollout translated each widget's existing classic layout into HTML/CSS -- same gradient headers, same rounded-shadowed card, prettier chrome around an unchanged composition. This actually redesigns weather (current/daily), calendar (all four views), tasks, and battery: no card/shadow anywhere, a slim accent-colored rule instead of a full gradient banner (and only that rule dithers at the richer accent amplitude now, not the header text sitting on it), and a dominant hero value (temperature/percent) instead of a centered icon+number of equal weight. Padding and type sizes scale as a clamped proportion of widget size instead of fixed pixel values. Text and static/whiteboard are left alone -- text already had zero chrome and its styling is user content, not this system's to redesign; framed_image's card was already minimal. Direction was picked from three divergent mockups reviewed with the maintainer, then verified against the real render pipeline (actual Chromium render, actual ordered dithering, actual theme system) rather than just eyeballed -- that caught a day-section/month-grid divider color (#e2e6ec) that's nowhere near this panel's 6-color palette and was dithering to invisible white; fixed with a real black hairline in the one place (month view) that still needed one.
This commit is contained in:
+136
-41
@@ -87,6 +87,20 @@ CATEGORY_EMOJI = {
|
||||
"thunderstorm": "⛈️",
|
||||
}
|
||||
|
||||
# Spelled-out condition word for the "bold minimal" current-mode layout --
|
||||
# classic's build_current never needed one (icon + temp only), but the
|
||||
# redesigned modern layout has room for a secondary line under the temp.
|
||||
CATEGORY_LABEL = {
|
||||
"clear": "Clear",
|
||||
"partly_cloudy": "Partly cloudy",
|
||||
"cloudy": "Cloudy",
|
||||
"fog": "Fog",
|
||||
"rain": "Rain",
|
||||
"snow": "Snow",
|
||||
"thunderstorm": "Thunderstorm",
|
||||
}
|
||||
|
||||
|
||||
def _rgb_to_hex(rgb: tuple[int, int, int]) -> str:
|
||||
return "#%02x%02x%02x" % tuple(rgb)
|
||||
|
||||
@@ -98,6 +112,15 @@ def _darken_hex(rgb: tuple[int, int, int], factor: float = 0.75) -> str:
|
||||
return _rgb_to_hex(tuple(max(0, round(c * factor)) for c in rgb))
|
||||
|
||||
|
||||
def _clamp(value: float, lo: float, hi: float) -> float:
|
||||
"""Keeps a size/spacing value proportional to widget dimensions
|
||||
(`value` is always some fraction of target_w/target_h) while still
|
||||
guaranteeing a floor (stays legible on a 1-2 grid-cell widget) and a
|
||||
ceiling (stops padding/type from just growing forever on a
|
||||
near-full-panel widget -- see build_current's docstring)."""
|
||||
return max(lo, min(hi, value))
|
||||
|
||||
|
||||
# --- Persistent background browser -------------------------------------
|
||||
|
||||
_loop: asyncio.AbstractEventLoop | None = None
|
||||
@@ -268,29 +291,65 @@ def _day_label(day_date: date) -> str:
|
||||
return day_date.strftime("%a")
|
||||
|
||||
|
||||
def _short_city(city_label: str) -> str:
|
||||
"""geocode_city (see docs/widgets.md's Weather widget section) hands
|
||||
back a full "City, Region, Country" string -- fine for classic's
|
||||
build_current (just drawn as one line, however wide) but wrong for
|
||||
the bold-minimal layout's small top-row label, where a 1-2 grid-
|
||||
cell widget has no room for the whole thing. Every phone-homescreen
|
||||
weather widget this style is drawing from shows just the city, so
|
||||
that's what this keeps -- CSS `text-overflow: ellipsis` is still in
|
||||
the template as a safety net for a custom single-segment label
|
||||
that's itself too long, not as the primary truncation strategy."""
|
||||
return city_label.split(",")[0].strip()
|
||||
|
||||
|
||||
def build_current(entry: dict | None, target_w: int, target_h: int, palette_rgb: list | None = None,
|
||||
units: str = "fahrenheit", city_label: str = "", theme_name: str | None = None) -> Image.Image:
|
||||
"""HTML/CSS-rendered analogue of weather_render.build_current --
|
||||
same call signature, so app/widgets/weather.py can dispatch to
|
||||
either interchangeably. Returns an already-palette-exact RGB image
|
||||
(see ordered_dither). No header/accent region here (just a centered
|
||||
icon+temp) -- theme-aware for font/radius only, plain ordered_dither
|
||||
(no ordered_dither_regions call)."""
|
||||
(see ordered_dither).
|
||||
|
||||
"Bold minimal" layout: the temperature itself is the graphic --
|
||||
city label + icon in a top row, the temp (dominant) and spelled-out
|
||||
condition anchored to the bottom, no card/border/shadow at all. This
|
||||
is a deliberate departure from every other modern-style widget's
|
||||
card-on-white-canvas chrome (see docs/widgets.md) -- there's nothing
|
||||
for a "card" to visually separate from here, so `theme["radius"]`/
|
||||
`theme["shadow"]` have no effect on this template; still theme-aware
|
||||
for font_family only, same as before. No header/accent region either
|
||||
(see ordered_dither_regions' docstring) -- a themed accent has
|
||||
nothing to attach to in a chrome-free layout.
|
||||
|
||||
Every size below is a fraction of `base` (the widget's shorter side),
|
||||
clamped to a floor/ceiling rather than fixed -- so a 1-grid-cell
|
||||
widget doesn't get comically oversized padding relative to its
|
||||
content, and a near-full-panel widget doesn't get comically large
|
||||
padding relative to *its* content either. Floors/ceilings are tuned
|
||||
by eye against real widget sizes, not derived from anything."""
|
||||
img = Image.new("RGB", (target_w, target_h), (255, 255, 255))
|
||||
if not entry:
|
||||
return img
|
||||
|
||||
theme = theme_tokens.resolve_theme(theme_name, "weather", palette_rgb)
|
||||
unit_suffix = "F" if units == "fahrenheit" else "C"
|
||||
icon_size = max(28, min(target_w, target_h) // 3)
|
||||
base = min(target_w, target_h)
|
||||
pad = _clamp(base * 0.09, 10, 26)
|
||||
icon_size = _clamp(base * 0.20, 22, 60)
|
||||
temp_size = _clamp(base * 0.46, 30, 150)
|
||||
deg_size = _clamp(temp_size * 0.28, 12, 40)
|
||||
cond_size = _clamp(base * 0.075, 11, 20)
|
||||
city_size = _clamp(base * 0.06, 10, 15)
|
||||
template = _jinja_env.get_template("weather_current.html.jinja")
|
||||
html = template.render(
|
||||
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=theme["radius"],
|
||||
w=target_w, h=target_h, pad=round(pad),
|
||||
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
|
||||
emoji=CATEGORY_EMOJI.get(entry["category"], ""),
|
||||
temp=round(entry["temp"]), unit_suffix=unit_suffix, city_label=city_label,
|
||||
icon_size=icon_size, temp_size=max(24, min(target_w, target_h) // 3),
|
||||
label_size=max(12, icon_size // 3),
|
||||
condition=CATEGORY_LABEL.get(entry["category"], ""),
|
||||
temp=round(entry["temp"]), unit_suffix=unit_suffix, city_label=_short_city(city_label),
|
||||
icon_size=round(icon_size), temp_size=round(temp_size), deg_size=round(deg_size),
|
||||
cond_size=round(cond_size), city_size=round(city_size),
|
||||
)
|
||||
rendered = render_html_to_image(html, target_w, target_h)
|
||||
return ordered_dither(rendered, palette_rgb)
|
||||
@@ -300,9 +359,20 @@ def build_daily(daily: dict[str, dict], target_w: int, target_h: int, palette_rg
|
||||
units: str = "fahrenheit", city_label: str = "", theme_name: str | None = None) -> Image.Image:
|
||||
"""HTML/CSS-rendered analogue of weather_render.build_daily -- same
|
||||
call signature. Returns an already-palette-exact RGB image (see
|
||||
ordered_dither). Has a header bar -- dithered at the theme's
|
||||
accent_amplitude via ordered_dither_regions, richer than the rest of
|
||||
the widget (see that function's docstring for why)."""
|
||||
ordered_dither).
|
||||
|
||||
"Bold minimal" layout, matching build_current: no card/border/
|
||||
shadow, a row of day columns each carrying its own high (dominant)
|
||||
/ low (muted) temp the same way build_current makes the current
|
||||
temp dominant. The old full-width gradient banner is gone --
|
||||
city_label, when set, is a slim accent-colored rule (not a block)
|
||||
with the city name understated beneath it, so there's still
|
||||
somewhere for a theme's accent hue to show up (dithered richer via
|
||||
ordered_dither_regions, same mechanism as before) without dragging
|
||||
back the "card with a colored header" chrome this redesign is
|
||||
moving away from. `theme["radius"]`/`theme["shadow"]` are unused
|
||||
here for the same reason as build_current -- no card for them to
|
||||
apply to."""
|
||||
img = Image.new("RGB", (target_w, target_h), (255, 255, 255))
|
||||
days = list(daily.items())
|
||||
if not days:
|
||||
@@ -310,9 +380,16 @@ def build_daily(daily: dict[str, dict], target_w: int, target_h: int, palette_rg
|
||||
|
||||
theme = theme_tokens.resolve_theme(theme_name, "weather", palette_rgb)
|
||||
unit_suffix = "F" if units == "fahrenheit" else "C"
|
||||
header_h = max(28, min(target_w, target_h) // 8) if city_label else 0
|
||||
col_w = max(1, target_w // len(days))
|
||||
icon_size = max(16, min(col_w // 2, 36))
|
||||
base = min(target_w, target_h)
|
||||
pad = round(_clamp(base * 0.08, 10, 22))
|
||||
col_w = max(1, (target_w - pad * 2) // len(days))
|
||||
col_gap = round(_clamp(col_w * 0.12, 4, 16))
|
||||
icon_size = round(_clamp(col_w * 0.30, 16, 32))
|
||||
day_label_size = round(_clamp(col_w * 0.15, 10, 14))
|
||||
high_size = round(_clamp(col_w * 0.32, 16, 32))
|
||||
low_size = round(max(9, high_size * 0.55))
|
||||
city_size = round(_clamp(base * 0.055, 10, 14))
|
||||
accent_h = round(_clamp(base * 0.025, 4, 8))
|
||||
day_entries = [
|
||||
{
|
||||
"label": _day_label(date.fromisoformat(day_str)),
|
||||
@@ -324,18 +401,17 @@ def build_daily(daily: dict[str, dict], target_w: int, target_h: int, palette_rg
|
||||
]
|
||||
template = _jinja_env.get_template("weather_daily.html.jinja")
|
||||
html = template.render(
|
||||
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=theme["radius"], shadow=theme["shadow"],
|
||||
w=target_w, h=target_h, pad=pad, col_gap=col_gap,
|
||||
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
|
||||
city_label=city_label, header_h=header_h,
|
||||
title_size=max(14, header_h - 12), accent_start=theme["accent_hex"], accent_end=theme["accent_hex_dark"],
|
||||
days=day_entries, icon_size=icon_size, label_size=max(12, icon_size // 2),
|
||||
unit_suffix=unit_suffix,
|
||||
city_label=_short_city(city_label), city_size=city_size, accent_h=accent_h,
|
||||
accent_start=theme["accent_hex"], accent_end=theme["accent_hex_dark"],
|
||||
days=day_entries, icon_size=icon_size, day_label_size=day_label_size,
|
||||
high_size=high_size, low_size=low_size, unit_suffix=unit_suffix,
|
||||
)
|
||||
rendered = render_html_to_image(html, target_w, target_h)
|
||||
if header_h <= 0:
|
||||
if not city_label:
|
||||
return ordered_dither(rendered, palette_rgb)
|
||||
gutter = panel_style.GUTTER
|
||||
accent_rect = (gutter, gutter, target_w - gutter, gutter + header_h)
|
||||
accent_rect = (pad, pad, target_w - pad, pad + accent_h)
|
||||
return ordered_dither_regions(rendered, palette_rgb, accent_regions=[(accent_rect, theme["accent_amplitude"])])
|
||||
|
||||
|
||||
@@ -375,12 +451,12 @@ def render_weather_preview_png(mode: str, data, orientation: str, palette_rgb: l
|
||||
|
||||
def _battery_sizes(target_w: int, target_h: int, num_lines: int, scale: float) -> dict:
|
||||
base = min(target_w, target_h)
|
||||
icon_h = max(16, int(base // 3 * scale))
|
||||
pct_size = max(14, int(base // 3 * scale))
|
||||
line_size = max(9, int(base // 9 * scale))
|
||||
gap = 8
|
||||
icon_h = max(14, int(base * 0.15 * scale))
|
||||
pct_size = max(20, int(base * 0.42 * scale))
|
||||
line_size = max(9, int(base * 0.085 * scale))
|
||||
gap = max(4, int(base * 0.035 * scale))
|
||||
total = icon_h + gap + pct_size + num_lines * (line_size + gap)
|
||||
return {"icon_h": icon_h, "pct_size": pct_size, "line_size": line_size, "total": total}
|
||||
return {"icon_h": icon_h, "pct_size": pct_size, "line_size": line_size, "gap": gap, "total": total}
|
||||
|
||||
|
||||
def build_battery(percent: int, lines: list[str], target_w: int, target_h: int,
|
||||
@@ -390,18 +466,26 @@ def build_battery(percent: int, lines: list[str], target_w: int, target_h: int,
|
||||
resolved by the caller (widgets/battery.py's _lines_for(), shared
|
||||
with the classic path so the estimate/age formatting only lives in
|
||||
one place). Returns an already-palette-exact RGB image (see
|
||||
ordered_dither). Theme-aware for font/radius only -- the charge-level
|
||||
ordered_dither). Theme-aware for font only -- the charge-level
|
||||
fill_color below is a functional status signal (not a style choice)
|
||||
and is never touched by a theme, and there's no header/accent region
|
||||
to dither richer via ordered_dither_regions.
|
||||
|
||||
Bold-minimal: no card (theme["radius"] unused, same carve-out as
|
||||
weather's build_current -- see docs/widgets.md); the percent is the
|
||||
hero value anchored toward the bottom, same treatment build_current
|
||||
gives the temperature, with the icon small and secondary above it
|
||||
instead of both competing at the same size like the old centered
|
||||
layout did.
|
||||
|
||||
Shrinks icon/text sizes together (in 0.05 steps down to 0.3x) until
|
||||
the whole stack actually fits the available height -- the classic
|
||||
PIL path solves the same "icon + percent + 0-2 lines in a fixed box"
|
||||
problem by truncating lines that don't fit; scaling down instead
|
||||
keeps every resolved line visible, which reads better for a widget
|
||||
that only ever has at most 2 short caption lines to begin with."""
|
||||
avail_h = target_h - panel_style.GUTTER * 2
|
||||
pad = round(_clamp(min(target_w, target_h) * 0.09, 10, 26))
|
||||
avail_h = target_h - pad * 2
|
||||
num_lines = len(lines)
|
||||
scale = 1.0
|
||||
sizes = _battery_sizes(target_w, target_h, num_lines, scale)
|
||||
@@ -426,13 +510,13 @@ def build_battery(percent: int, lines: list[str], target_w: int, target_h: int,
|
||||
nub_w = max(3, icon_w // 10)
|
||||
template = _jinja_env.get_template("battery.html.jinja")
|
||||
html = template.render(
|
||||
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=theme["radius"],
|
||||
w=target_w, h=target_h, pad=pad,
|
||||
font_regular=theme["font_regular"], font_bold=theme["font_bold"], percent=percent, lines=lines,
|
||||
icon_w=icon_w, icon_h=icon_h, icon_radius=icon_h // 6, stroke=stroke,
|
||||
fill_pct=max(0, min(100, percent)), fill_radius=max(0, icon_h // 6 - stroke),
|
||||
fill_color=_rgb_to_hex(fill_color), fill_color_dark=_darken_hex(fill_color),
|
||||
nub_w=nub_w, nub_h=icon_h // 2, nub_radius=max(1, nub_w // 3),
|
||||
pct_size=sizes["pct_size"], line_size=sizes["line_size"],
|
||||
pct_size=sizes["pct_size"], line_size=sizes["line_size"], line_gap=sizes["gap"],
|
||||
)
|
||||
rendered = render_html_to_image(html, target_w, target_h)
|
||||
return ordered_dither(rendered, palette_rgb)
|
||||
@@ -502,17 +586,28 @@ def build_tasks(tasks: list[dict], target_w: int, target_h: int, palette_rgb: li
|
||||
logic the classic renderer uses) so a task's color chip/due string
|
||||
matches classic style exactly; only the drawing differs -- and a
|
||||
theme's accent never touches those per-owner chip colors (identity-
|
||||
coding, not style). Has a header bar -- dithered at the theme's
|
||||
accent_amplitude via ordered_dither_regions. Returns an already-
|
||||
palette-exact RGB image (see ordered_dither)."""
|
||||
coding, not style) or the done-checkbox fill (a completion state
|
||||
signal, not a style choice -- it happens to reuse the accent color,
|
||||
but that's incidental, same as before this redesign).
|
||||
|
||||
Bold-minimal: no card (theme["shadow"]/["radius"] unused, same
|
||||
carve-out as calendar's redesigned views -- see docs/widgets.md).
|
||||
The old gradient header banner is now a slim accent rule + plain
|
||||
bold title, matching every calendar view's day-header language --
|
||||
only the rule dithers at the theme's richer accent_amplitude, not
|
||||
the title text sitting on it. Returns an already-palette-exact RGB
|
||||
image (see ordered_dither)."""
|
||||
from .calendar_render import _event_colors, _fmt_task_due
|
||||
|
||||
theme = theme_tokens.resolve_theme(theme_name, "tasks", palette_rgb)
|
||||
header_h = max(28, min(target_w, target_h) // 8)
|
||||
body_size = max(11, min(target_w, target_h) // 20)
|
||||
base = min(target_w, target_h)
|
||||
accent_h = round(_clamp(base * 0.025, 3, 6))
|
||||
title_size = max(14, base // 12)
|
||||
body_size = max(11, base // 20)
|
||||
row_h = body_size + 14
|
||||
box_size = max(10, body_size - 4)
|
||||
avail_h = target_h - header_h - 16
|
||||
header_h = accent_h + 6 + title_size
|
||||
avail_h = target_h - panel_style.GUTTER * 2 - header_h - 8
|
||||
max_rows = max(0, avail_h // row_h)
|
||||
|
||||
owners_seen: list[str] = []
|
||||
@@ -531,15 +626,15 @@ def build_tasks(tasks: list[dict], target_w: int, target_h: int, palette_rgb: li
|
||||
|
||||
template = _jinja_env.get_template("tasks.html.jinja")
|
||||
html = template.render(
|
||||
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=theme["radius"], shadow=theme["shadow"],
|
||||
w=target_w, h=target_h, gutter=panel_style.GUTTER,
|
||||
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
|
||||
title=title, header_h=header_h, title_size=max(14, header_h - 12),
|
||||
accent_start=theme["accent_hex"], accent_end=theme["accent_hex_dark"],
|
||||
title=title, header_h=header_h, accent_h=accent_h, title_size=title_size,
|
||||
accent_start=theme["accent_hex"],
|
||||
rows=rows, more_count=more_count, row_h=row_h, box_size=box_size, body_size=body_size,
|
||||
)
|
||||
rendered = render_html_to_image(html, target_w, target_h)
|
||||
gutter = panel_style.GUTTER
|
||||
accent_rect = (gutter, gutter, target_w - gutter, gutter + header_h)
|
||||
accent_rect = (gutter, gutter, target_w - gutter, gutter + accent_h)
|
||||
return ordered_dither_regions(rendered, palette_rgb, accent_regions=[(accent_rect, theme["accent_amplitude"])])
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user