From cae44cf31d8937f88519fc391ff957ca4c7c50c9 Mon Sep 17 00:00:00 2001 From: John Osumi <931193+sumitake@users.noreply.github.com> Date: Thu, 6 Aug 2026 23:06:52 -0700 Subject: [PATCH 1/9] docs: animation-accents design spec + implementation plan Co-Authored-By: Claude Opus 4.8 --- .../plans/2026-08-06-animation-accents.md | 519 ++++++++++++++++++ .../2026-08-06-animation-accents-design.md | 111 ++++ 2 files changed, 630 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-06-animation-accents.md create mode 100644 docs/superpowers/specs/2026-08-06-animation-accents-design.md diff --git a/docs/superpowers/plans/2026-08-06-animation-accents.md b/docs/superpowers/plans/2026-08-06-animation-accents.md new file mode 100644 index 0000000..ca8669f --- /dev/null +++ b/docs/superpowers/plans/2026-08-06-animation-accents.md @@ -0,0 +1,519 @@ +# Stock-Animation Accents Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add device stock-animation accents to the two existing integrations — animated calendar icons at the 5-min/1-min escalation stages, a full-panel animation takeover for the first 60 s after an event starts, and an animated spinner on the CI running badge. + +**Architecture:** Pure-logic changes in each integration's `logic.py` (new element construction gated by config), threaded through `main.py`; stock animations are referenced in place via `stock_path` (no assets bundled). Calendar `main.run_once` adopts `ci_status`'s proven unified shape-tracker clear so the new id-set transitions clear correctly. + +**Tech Stack:** Python ≥3.12, stdlib + `requests`; pytest. No new dependencies. + +**Reference spec:** `docs/superpowers/specs/2026-08-06-animation-accents-design.md` (spikes resolved §7). + +## Global Constraints + +- Public repo — sanitize; never print `config.toml`; no secrets. +- Runtime deps unchanged (stdlib + `requests`). **No new assets** — stock animations referenced via `stock_path` form **`shared/.anim`** (verified working). +- Stock names (exact): `calendar_event_16x16` (5-min), `calendar_reminder_16x16` (1-min), `spinner_front_8x8` (CI), and the configurable `start_animation` (default `meeting_72x16`). +- Config defaults (exact): `[calendar_countdown]` `escalation_icons=true`, `start_animation="meeting_72x16"`, `start_window_seconds=60`; `[ci_status]` `running_spinner=true`. +- Priorities: warn/imminent stay `PRIORITY_AMBIENT_URGENT` (65, unchanged); the just-started takeover is 65; in-progress past the window is `PRIORITY_AMBIENT` (20, unchanged). +- Every animation element: `{"type": "animation", "loop": true, "timeout": timeout_s}` with `x`/`y` as specified. +- Icon at `x=0, y=0` (16×16); the countdown numeral keeps its existing `CD_TEXT_X=39`; spinner at `x=64, y=0` (8×8). +- Tests: `uv run pytest`. Follow existing patterns (caller-owned `state` dict; `run_once(...) -> str`; commit-on-`DRAWN`). +- Backward-compat: new `build_elements`/`select_priority`/`_build_running_elements`/`build_overlay_payload` params default to the pre-feature behavior so existing tests/callers are unaffected. + +## File Structure + +- `src/busybar/config.py` — new default keys. **(Task 1)** +- `integrations/calendar_countdown/logic.py` — `is_just_started`, icon/takeover elements, `select_priority` just-started tier. **(Task 2)** +- `integrations/calendar_countdown/main.py` — thread `just_started`; unified shape-tracker clear. **(Task 3)** +- `integrations/ci_status/logic.py` — running-badge spinner (gated). **(Task 4)** +- `integrations/calendar_countdown/README.md`, `integrations/ci_status/README.md`, `config.example.toml` — docs/config. **(Task 5)** +- Tests: `tests/test_config.py`, `tests/test_calendar_logic.py`, `tests/test_calendar_loop.py`, `tests/test_ci_logic.py`. +- On-device verification. **(Task 6)** + +--- + +### Task 1: Config defaults + +**Files:** +- Modify: `src/busybar/config.py` (`DEFAULTS`) +- Test: append to `tests/test_config.py` + +**Interfaces:** +- Produces: `DEFAULTS["calendar_countdown"]["escalation_icons"|"start_animation"|"start_window_seconds"]`, `DEFAULTS["ci_status"]["running_spinner"]`. + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_config.py (append) +from busybar.config import load_config + +def test_animation_accent_defaults(): + cfg = load_config(path=None) + cal = cfg["calendar_countdown"] + assert cal["escalation_icons"] is True + assert cal["start_animation"] == "meeting_72x16" + assert cal["start_window_seconds"] == 60 + assert cfg["ci_status"]["running_spinner"] is True +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_config.py::test_animation_accent_defaults -v` +Expected: FAIL (KeyError). + +- [ ] **Step 3: Add the defaults** — in `src/busybar/config.py`, add these keys to the existing `DEFAULTS["calendar_countdown"]` dict (alongside the current keys): + +```python + # Stock-animation accents (2026-08-06). Icons/animation are device + # stock, referenced by stock_path -- no assets bundled. + "escalation_icons": True, # animated calendar icons at warn (5m) / imminent (1m) + "start_animation": "meeting_72x16", # full-panel takeover for the first minute after + # start (aligned with the T-0 chirp); "" disables + "start_window_seconds": 60, # how long the start takeover holds (also its urgent-priority window) +``` + +and add to `DEFAULTS["ci_status"]`: + +```python + "running_spinner": True, # animated 8x8 spinner on the running badge +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_config.py -v` +Expected: PASS (all). + +- [ ] **Step 5: Commit** + +```bash +git add src/busybar/config.py tests/test_config.py +git commit -m "config: add stock-animation accent defaults for calendar + ci" +``` + +--- + +### Task 2: Calendar logic — icons, start takeover, priority + +**Files:** +- Modify: `integrations/calendar_countdown/logic.py` +- Test: append to `tests/test_calendar_logic.py` + +**Interfaces:** +- Consumes: existing `PRIORITY_AMBIENT_URGENT`, `CD_TEXT_X`, `_state_for`, `_minutes_left`, `STATE_WARNING`, `BG_GRADIENT`, `CalEvent`. +- Produces: + - `ICON_EVENT = "calendar_event_16x16"`, `ICON_REMINDER = "calendar_reminder_16x16"`, `ICON_X = 0`, `ICON_Y = 0`, `ICON_TITLE_X = 18`, `START_ANIM_ID = "cal_start_anim"`, `CAL_ICON_ID = "cal_icon"`. + - `is_just_started(event: CalEvent, now: datetime, in_progress: bool, start_window_seconds: int, start_animation: str) -> bool` + - `select_priority(minutes_left, approach_minutes, notice_minutes, in_progress, just_started: bool = False) -> int` (new trailing param) + - `build_elements(event, now, cfg, timeout_s, in_progress, just_started: bool = False) -> list[dict]` (new trailing param) + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/test_calendar_logic.py (append) +from datetime import datetime, timedelta, timezone +from integrations.calendar_countdown.logic import ( + is_just_started, select_priority, build_elements, CalEvent, + START_ANIM_ID, CAL_ICON_ID, ICON_EVENT, ICON_REMINDER) +from busybar.display import PRIORITY_AMBIENT_URGENT, PRIORITY_AMBIENT + +def _ev(start): # 30-min event + return CalEvent(title="Standup", start=start, end=start + timedelta(minutes=30), all_day=False) + +def _cfg(**over): + base = {"poll_seconds": 10, "lookahead_hours": 12, "warn_minutes": 5, "notice_minutes": 15, + "approach_minutes": 30, "imminent_minutes": 1, "progress_window_minutes": 60, + "escalation_icons": True, "start_animation": "meeting_72x16", "start_window_seconds": 60} + base.update(over); return base + +NOW = datetime(2026, 8, 6, 12, 0, tzinfo=timezone.utc) + +def test_is_just_started_window(): + ev = _ev(NOW - timedelta(seconds=30)) # started 30s ago + assert is_just_started(ev, NOW, True, 60, "meeting_72x16") is True + ev2 = _ev(NOW - timedelta(seconds=90)) # started 90s ago + assert is_just_started(ev2, NOW, True, 60, "meeting_72x16") is False + assert is_just_started(ev, NOW, True, 60, "") is False # disabled + assert is_just_started(ev, NOW, False, 60, "meeting_72x16") is False # not in progress + +def test_priority_just_started_is_urgent(): + assert select_priority(0.0, 30, 15, True, just_started=True) == PRIORITY_AMBIENT_URGENT + assert select_priority(0.0, 30, 15, True, just_started=False) == PRIORITY_AMBIENT # unchanged + +def test_warn_stage_adds_event_icon(): + ev = _ev(NOW + timedelta(minutes=4)) # 4m out -> warn, > imminent + els = build_elements(ev, NOW, _cfg(), 15, in_progress=False) + icon = next(e for e in els if e["id"] == CAL_ICON_ID) + assert icon["type"] == "animation" and icon["stock_path"] == f"shared/{ICON_EVENT}.anim" + assert icon["x"] == 0 and icon["y"] == 0 + assert any(e["id"] == "title" for e in els) # title still present at warn + +def test_imminent_stage_uses_reminder_icon_and_drops_title(): + ev = _ev(NOW + timedelta(seconds=30)) # 0.5m out -> imminent + els = build_elements(ev, NOW, _cfg(), 15, in_progress=False) + icon = next(e for e in els if e["id"] == CAL_ICON_ID) + assert icon["stock_path"] == f"shared/{ICON_REMINDER}.anim" + assert not any(e["id"] == "title" for e in els) # title dropped at imminent + +def test_just_started_returns_takeover_animation(): + ev = _ev(NOW - timedelta(seconds=10)) + els = build_elements(ev, NOW, _cfg(), 15, in_progress=True, just_started=True) + anim = next(e for e in els if e["id"] == START_ANIM_ID) + assert anim["type"] == "animation" and anim["stock_path"] == "shared/meeting_72x16.anim" + assert not any(e["id"] in ("cd_text", "ends") for e in els) # takeover replaces the countdown + +def test_escalation_icons_off_is_unchanged(): + ev = _ev(NOW + timedelta(minutes=4)) + els = build_elements(ev, NOW, _cfg(escalation_icons=False), 15, in_progress=False) + assert not any(e["id"] == CAL_ICON_ID for e in els) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_calendar_logic.py -k "just_started or icon or priority_just" -v` +Expected: FAIL (ImportError / new params missing). + +- [ ] **Step 3: Implement in `logic.py`** + +Add near the other module constants: + +```python +ICON_EVENT = "calendar_event_16x16" +ICON_REMINDER = "calendar_reminder_16x16" +ICON_X, ICON_Y = 0, 0 +ICON_TITLE_X = 18 # title shifts right of the 16x16 icon (icon occupies x=0..15) +CAL_ICON_ID = "cal_icon" +START_ANIM_ID = "cal_start_anim" +``` + +Add the pure predicate: + +```python +def is_just_started(event: CalEvent, now: datetime, in_progress: bool, + start_window_seconds: int, start_animation: str) -> bool: + """True for the first `start_window_seconds` after an event begins, when a + start-takeover animation is configured. The window aligns with the T-0 + chirp and holds the display at urgent priority as a 'running late' alarm.""" + if not in_progress or not start_animation: + return False + return (now - event.start).total_seconds() < start_window_seconds +``` + +Add the `just_started` tier to `select_priority` (new trailing param, checked first): + +```python +def select_priority(minutes_left, approach_minutes, notice_minutes, in_progress, + just_started: bool = False) -> int: + if just_started: + return PRIORITY_AMBIENT_URGENT + # ... existing body unchanged ... +``` + +In `build_elements`, add the trailing param `just_started: bool = False` and, at the very top of the body, the takeover short-circuit: + +```python + if just_started: + bg = {"id": "bg", "type": "rectangle", "x": 0, "y": 0, + "width": PANEL_WIDTH, "height": PANEL_HEIGHT, "fill": "gradient_v", + "fill_colors": BG_GRADIENT[STATE_IN_PROGRESS], "border_width": 0, "timeout": timeout_s} + anim = {"id": START_ANIM_ID, "type": "animation", + "stock_path": f"shared/{cfg['start_animation']}.anim", + "x": 0, "y": 0, "loop": True, "timeout": timeout_s} + return [bg, anim] +``` + +Then, in the existing NOT-in_progress (upcoming) path, after the `title_element`/countdown are built and before assembling the final `elements` list, add the icon and adjust the title. Compute the stage from the already-available `minutes_left` and `cfg`: + +```python + icon_element = None + if not in_progress and cfg.get("escalation_icons") and state == STATE_WARNING: + imminent = minutes_left <= cfg["imminent_minutes"] + icon_name = ICON_REMINDER if imminent else ICON_EVENT + icon_element = {"id": CAL_ICON_ID, "type": "animation", + "stock_path": f"shared/{icon_name}.anim", + "x": ICON_X, "y": ICON_Y, "loop": True, "timeout": timeout_s} + if imminent: + title_element = None # drop the title at imminent -> icon + big number + else: + title_element.update({"x": ICON_TITLE_X, + "width": CD_TEXT_X - ICON_TITLE_X - 2}) # scroll in the gap +``` + +Assemble `elements` so `title_element` is included only when not None, and append `icon_element` when present (draw order: icon after bg/title so it sits on top of the bg). Keep the existing in-progress branch unchanged (it runs only when `not just_started`). + +*(The implementer reads the existing `build_elements` to integrate these; `state`, `minutes_left`, `title_element`, `CD_TEXT_X`, `STATE_WARNING`, `STATE_IN_PROGRESS`, `BG_GRADIENT`, `PANEL_WIDTH`/`PANEL_HEIGHT` are already in scope there.)* + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_calendar_logic.py -v` +Expected: PASS (all, including pre-existing). + +- [ ] **Step 5: Commit** + +```bash +git add integrations/calendar_countdown/logic.py tests/test_calendar_logic.py +git commit -m "calendar: escalation icons, start-takeover animation, just-started priority" +``` + +--- + +### Task 3: Calendar main — thread just_started + unified shape-tracker clear + +**Files:** +- Modify: `integrations/calendar_countdown/main.py` (`run_once`) +- Test: append to `tests/test_calendar_loop.py` + +**Interfaces:** +- Consumes: `logic.is_just_started`, `build_elements`/`select_priority` new params. +- Produces: `run_once` draws the takeover at priority 65 during the start window and clears on any element-id-set change (`state["last_shape"]`), replacing the old `state["in_progress"]`-transition clear. + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/test_calendar_loop.py (append) +from datetime import datetime, timedelta, timezone +from busybar.client import DrawResult +from busybar.display import PRIORITY_AMBIENT_URGENT +from integrations.calendar_countdown.main import run_once +from integrations.calendar_countdown.logic import CalEvent, START_ANIM_ID + +class FakeClient: + def __init__(self): self.draws=[]; self.clears=0 + def draw(self, app, elements, priority=50, led_notification_color=None): + self.draws.append((elements, priority)); return DrawResult.DRAWN + def clear(self, app): self.clears += 1; return True + def get_busy(self): return {} + def play_audio(self, *a, **k): return True + def set_busy_simple(self, *a, **k): return True + +NOW = datetime(2026, 8, 6, 12, 0, tzinfo=timezone.utc) +def _cfg(**o): + b={"poll_seconds":10,"lookahead_hours":12,"warn_minutes":5,"notice_minutes":15, + "approach_minutes":30,"imminent_minutes":1,"progress_window_minutes":60,"include_all_day":False, + "auto_busy":False,"calendars":[],"chirp":False,"escalation_icons":True, + "start_animation":"meeting_72x16","start_window_seconds":60} + b.update(o); return {"calendar_countdown": b} +def _fetch(ev): # fetch(lookahead) -> [ev] + return lambda hours: [ev] + +def test_just_started_draws_takeover_at_urgent(): + ev = CalEvent("Standup", NOW - timedelta(seconds=15), NOW + timedelta(minutes=29), False) + c = FakeClient(); st = {} + run_once(c, _fetch(ev), _cfg(), NOW, dry_run=False, state=st) + elements, priority = c.draws[-1] + assert priority == PRIORITY_AMBIENT_URGENT + assert any(e["id"] == START_ANIM_ID for e in elements) + +def test_shape_change_triggers_clear(): + # First poll: warn stage (icon+title). Second poll: takeover (different id-set) -> clear. + ev = CalEvent("Standup", NOW + timedelta(minutes=4), NOW + timedelta(minutes=34), False) + c = FakeClient(); st = {} + run_once(c, _fetch(ev), _cfg(), NOW, dry_run=False, state=st) # warn + ev2 = CalEvent("Standup", NOW - timedelta(seconds=10), NOW + timedelta(minutes=29), False) + run_once(c, _fetch(ev2), _cfg(), NOW, dry_run=False, state=st) # takeover + assert c.clears >= 1 # id-set changed -> cleared before the takeover draw +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_calendar_loop.py -k "just_started or shape_change" -v` +Expected: FAIL. + +- [ ] **Step 3: Implement in `main.run_once`** + +After `event`/`in_progress` are resolved and before building elements, compute: + +```python + just_started = is_just_started(event, now, in_progress, + c["start_window_seconds"], c["start_animation"]) +``` + +Pass it through: + +```python + elements = build_elements(event, now, c, timeout_s, in_progress, just_started=just_started) + minutes_left = _minutes_left(event, now, in_progress) + priority = select_priority(minutes_left, c["approach_minutes"], c["notice_minutes"], + in_progress, just_started=just_started) +``` + +Replace the existing `state["in_progress"]`-transition clear block (the `if state is not None and state.get("in_progress") not in (None, in_progress): client.clear(APP)`) with a unified shape-tracker clear (mirrors `ci_status`): + +```python + new_shape = frozenset(e["id"] for e in elements) + if state is not None: + last_shape = state.get("last_shape") + if last_shape is not None and last_shape != new_shape: + client.clear(APP) # id-set changed -> drop stale elements first (same as ci_status) +``` + +In the `result == DrawResult.DRAWN` commit block, replace the `state["in_progress"] = in_progress` line with: + +```python + state["last_shape"] = new_shape +``` + +In the `event is None` path, replace `state["in_progress"] = None` with `state["last_shape"] = None` (device is now blank). Leave `state["next_start"]`, the LED bookkeeping, and the chirp logic untouched. + +Add `is_just_started` to the existing `from .logic import (...)` line. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_calendar_loop.py -v` +Expected: PASS (all, including pre-existing). + +- [ ] **Step 5: Commit** + +```bash +git add integrations/calendar_countdown/main.py tests/test_calendar_loop.py +git commit -m "calendar: draw start-takeover at urgent; unified shape-tracker clear" +``` + +--- + +### Task 4: CI running-badge spinner + +**Files:** +- Modify: `integrations/ci_status/logic.py` (`_build_running_elements`, `build_overlay_payload`), `integrations/ci_status/main.py` (pass `show_spinner`) +- Test: append to `tests/test_ci_logic.py` + +**Interfaces:** +- Produces: `_build_running_elements(info, timeout_s, show_spinner: bool = False)`, `build_overlay_payload(..., show_spinner: bool = False)`; constants `RUN_SPINNER_ID = "run_spinner"`, `SPINNER_STOCK = "shared/spinner_front_8x8.anim"`. + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/test_ci_logic.py (append) +from datetime import datetime, timezone +from integrations.ci_status.logic import ( + build_overlay_payload, RunningInfo, OVERLAY_FRAME_CI_BADGE, RUN_SPINNER_ID) + +NOW = datetime(2026, 8, 6, 12, 0, tzinfo=timezone.utc) +def _running(): + return RunningInfo(run={"name": "build", "run_started_at": "2026-08-06T11:58:00Z", + "pull_requests": [{"number": 42}], "workflow_id": 1}, + repo="me/repo", other_count=0, median_minutes=8.0, now=NOW) + +def test_spinner_present_and_title_reserved_when_on(): + p = build_overlay_payload(OVERLAY_FRAME_CI_BADGE, 10, running=_running(), show_spinner=True) + els = p["elements"] + spin = next(e for e in els if e["id"] == RUN_SPINNER_ID) + assert spin["type"] == "animation" and spin["stock_path"] == "shared/spinner_front_8x8.anim" + assert spin["x"] == 64 and spin["y"] == 0 + title = next(e for e in els if e["id"] == "title") + assert title["width"] == 60 # reserved so the scrolling title never runs under the spinner + +def test_no_spinner_and_full_title_when_off(): + p = build_overlay_payload(OVERLAY_FRAME_CI_BADGE, 10, running=_running(), show_spinner=False) + els = p["elements"] + assert not any(e["id"] == RUN_SPINNER_ID for e in els) + assert next(e for e in els if e["id"] == "title")["width"] == 68 # unchanged +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_ci_logic.py -k spinner -v` +Expected: FAIL. + +- [ ] **Step 3: Implement in `ci_status/logic.py`** + +Add constants near the running-badge section: + +```python +RUN_SPINNER_ID = "run_spinner" +SPINNER_STOCK = "shared/spinner_front_8x8.anim" +RUNNING_TITLE_WIDTH_SPINNER = 60 # reserve the top-right 8x8 corner (spinner at x=64) +``` + +In `_build_running_elements(info, timeout_s)` add the trailing param `show_spinner: bool = False`. Where the title element's `width` is set to `RUNNING_TITLE_WIDTH`, use `RUNNING_TITLE_WIDTH_SPINNER if show_spinner else RUNNING_TITLE_WIDTH` (and use the same value in the `_title_fits` scroll decision). After the elements list is assembled, before `return`: + +```python + if show_spinner: + elements.append({"id": RUN_SPINNER_ID, "type": "animation", "stock_path": SPINNER_STOCK, + "x": 64, "y": 0, "loop": True, "timeout": timeout_s}) +``` + +In `build_overlay_payload`, add trailing param `show_spinner: bool = False` and pass it through only on the CI-badge branch: + +```python + if frame_name == OVERLAY_FRAME_CI_BADGE: + if running is None: + return None + return {"elements": _build_running_elements(running, timeout_s, show_spinner=show_spinner), + "priority": PRIORITY_OVERLAY, "led": None} +``` + +(Quota branches unchanged — they never get a spinner.) + +- [ ] **Step 4: Thread the config in `ci_status/main.py`** + +At the `build_overlay_payload(...)` call in `run_once`, add `show_spinner=c["running_spinner"]`. + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `uv run pytest tests/test_ci_logic.py -v` +Expected: PASS (all). + +- [ ] **Step 6: Commit** + +```bash +git add integrations/ci_status/logic.py integrations/ci_status/main.py tests/test_ci_logic.py +git commit -m "ci: animated spinner on the running badge (config-gated)" +``` + +--- + +### Task 5: Docs + config example + +**Files:** +- Modify: `integrations/calendar_countdown/README.md`, `integrations/ci_status/README.md`, `config.example.toml` + +- [ ] **Step 1: Update `config.example.toml`** — add to the existing `[calendar_countdown]` block: + +```toml +escalation_icons = true # animated calendar icons at the 5-min / 1-min stages +start_animation = "meeting_72x16" # full-panel takeover for the first minute after an event starts; "" disables +start_window_seconds = 60 # how long that takeover holds +``` + +and to the existing `[ci_status]` block: + +```toml +running_spinner = true # animated 8x8 spinner on the running badge +``` + +- [ ] **Step 2: Update the two integration READMEs** — in `calendar_countdown/README.md`, document: the animated calendar icons at warn (5-min, `calendar_event`) and imminent (1-min, `calendar_reminder`, title dropped); the full-panel **start takeover** for the first `start_window_seconds` after an event begins (held at urgent priority, aligned with the T-0 chirp, shows the fixed word of the chosen `start_animation`, configurable/`""`-disables); that stock animations are referenced by `stock_path` (no assets bundled). In `ci_status/README.md`, document the `running_spinner` on the running badge (quota frames unaffected). Reference `docs/superpowers/specs/2026-08-06-animation-accents-design.md`. + +- [ ] **Step 3: Verify suite still green** + +Run: `uv run pytest -q` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add integrations/calendar_countdown/README.md integrations/ci_status/README.md config.example.toml +git commit -m "docs: document animation accents + config for calendar and ci" +``` + +--- + +### Task 6: On-device verification (operator/primary pass — not a subagent task) + +Requires the live device and the two agents restarted (`launchctl kickstart -k gui/$(id -u)/com.busybar.calendar-countdown` and `.../com.busybar.ci-status` after the branch is checked out, or run `--once` from `integrations/`). Manual/primary checklist: + +- [ ] **Calendar warn (5-min):** event icon appears left, title + amber countdown visible, no clipping. +- [ ] **Calendar imminent (1-min):** reminder-bell icon left, big red countdown, title dropped, LED blinks (existing) — and confirm the title's old pixels are gone (shape clear worked). +- [ ] **Calendar start takeover:** at T-0 (with the chirp), the full-panel animation shows for ~60 s at urgent priority, then reverts cleanly to the "ENDS" display (no stale takeover pixels). +- [ ] **Warn title legibility:** confirm the scrolling title in the narrow `x=18..38` band reads acceptably; if cramped, apply the spec §9 fallback (drop the title at warn too). +- [ ] **CI running spinner:** during a real run, the 8×8 spinner animates top-right and the title/ETA are not occluded; quota frames show no spinner. +- [ ] Capture framebuffers (`/api/screen?display=0`) for each state as evidence. + +--- + +## Self-Review + +**1. Spec coverage:** §4a warn/imminent icons → Task 2 (+3 wiring); §4b start takeover + priority → Tasks 2/3; §5 CI spinner + title reserve → Task 4; §6 config → Task 1 (+5 example); §3 stock_path form → constants in Tasks 2/4; §8 tests → Tasks 1–4; on-device → Task 6. ✓ +**2. Placeholder scan:** No TBD/TODO; every code step has concrete code; Task 6 is explicitly a manual pass. The `build_elements` integration references in-scope existing symbols and gives the exact new blocks. ✓ +**3. Type consistency:** `just_started` trailing-param default `False` consistent across `is_just_started`/`select_priority`/`build_elements`; `show_spinner` default `False` across `_build_running_elements`/`build_overlay_payload`; ids (`cal_icon`/`cal_start_anim`/`run_spinner`) and constants reused verbatim between logic and tests; `state["last_shape"]` (frozenset) consistent with the commit + `event is None` paths. ✓ diff --git a/docs/superpowers/specs/2026-08-06-animation-accents-design.md b/docs/superpowers/specs/2026-08-06-animation-accents-design.md new file mode 100644 index 0000000..9c461c1 --- /dev/null +++ b/docs/superpowers/specs/2026-08-06-animation-accents-design.md @@ -0,0 +1,111 @@ +# Stock-Animation Accents for calendar_countdown + ci_status — Design Spec + +**Date:** 2026-08-06 +**Status:** approved design; on-device composition spikes resolved (§7) +**Depends on:** existing `calendar_countdown` and `ci_status` integrations, `busybar.display` priority ladder, `AnimationElement` (established during nyan_filler work: `2026-08-06-nyan-filler-design.md` §5b). + +--- + +## 1. Goal + +Bring the device's built-in stock animations into the two existing integrations to make the important moments more alive: + +- **Calendar** — animated calendar icons accenting the pre-start escalation (5-min and 1-min), and a full-panel animation takeover for the **first 60 s after an event begins** (aligned with the T-0 chirp) as a strong "it's starting / you're running late" alarm. +- **CI** — a small animated "working" spinner on the running badge. + +## 2. Scope & non-goals + +**In scope:** edits to `calendar_countdown/logic.py` (+ `main.py`/`select_priority` for the start window), `ci_status/logic.py` (running badge only), config additions, agent restarts. + +**Non-goals:** +- **No quota animations** — the quota frames are %-gauges with no thematic stock animation; left unchanged (operator's choice). +- **No new integration, no new assets** — stock animations are referenced in place via `stock_path`; nothing is uploaded or bundled. +- No change to the notice (15-min/amber) stage, the approach window, or the CI alert/quiet-green/quota displays. + +## 3. Stock animations used + +Referenced by `stock_path` (form **`shared/.anim`**, verified working — §7). All are device stock at `/ext/apps_assets/shared/animations/`: + +| Use | Animation | Size | +|---|---|---| +| Calendar 5-min accent | `calendar_event_16x16` | 16×16 | +| Calendar 1-min accent | `calendar_reminder_16x16` (calendar + bell) | 16×16 | +| Calendar start takeover (configurable) | `meeting_72x16` (default) | 72×16 | +| CI running spinner | `spinner_front_8x8` | 8×8 | + +The full-panel stock graphics **bake in their own word** ("MEETING", "BOOKED", …) and light ~97 % of the panel, so the start takeover is a genuine full-screen replacement showing that fixed word for every event (a documented tradeoff; configurable per §6). + +## 4. Calendar — three animation moments + +`build_elements` (and `select_priority`) gain the following. All draws remain a **single** `client.draw` of mixed elements (composition verified §7); the animation is one more element layered by draw order. + +### 4a. Pre-start accents (countdown preserved) + +| Stage | Trigger | Elements | +|---|---|---| +| **Warn** | `minutes_left ≤ warn_minutes` (5) and `> imminent_minutes` (1) | existing red `bg` + drain `track` + **`calendar_event_16x16` icon at (x=0, y=0)** + title shifted to `x=18` (reduced width, scrolls) + large countdown at existing `CD_TEXT_X=39` | +| **Imminent** | `minutes_left ≤ imminent_minutes` (1), not yet started | red `bg` + **`calendar_reminder_16x16` icon at (x=0, y=0)** + **title dropped** + large red countdown at `CD_TEXT_X=39` + LED blink (existing) | + +Priority unchanged: both are `PRIORITY_AMBIENT_URGENT` (65) exactly as today. Element-id set changes (adds `cal_icon`); the existing transition-clear logic already clears on id-set change. + +Layout: icon occupies `x=0..15`; the countdown numeral keeps its current right-side position (`x=39`), so the icon and number never overlap. At warn, the title lives in the `x=18..38` gap and scrolls; at imminent the title is dropped so the icon + big number own the panel. + +### 4b. Start takeover (new — first 60 s after start) + +A new window `just_started` = `in_progress` **and** `elapsed_since_start < start_window_seconds` (default 60). + +- **Display:** a single full-panel `AnimationElement` (`stock_path` = the configured `start_animation`, default `meeting_72x16`, `x=0, y=0, loop=true`) — replaces the normal in-progress "ENDS" display for this window. +- **Priority:** held at `PRIORITY_AMBIENT_URGENT` (65) for the whole window — so it preempts the filler/ambient and reads as an alarm, matching the T-0 chirp. `select_priority` returns 65 when `just_started`, else the existing behavior (in-progress → `PRIORITY_AMBIENT` 20). +- **After the window:** reverts to the normal in-progress "ENDS" display at `PRIORITY_AMBIENT` (20). The id-set change (full-panel `cal_start_anim` → `ends`/`time` set) triggers the existing transition-clear. +- **Disabled** (`start_animation = ""`): the window is skipped entirely — in-progress behaves exactly as today from T-0. + +The chirp (T-0, existing) and this takeover are independent but coincide by construction; no coupling between them beyond both keying off event start. + +## 5. CI — running-badge spinner + +In `_build_running_elements` only (never the quota frames): + +- Add `spinner_front_8x8` at **(x=64, y=0)** (top-right corner), `loop=true`. +- **Reserve its corner:** reduce the running title's available width from `RUNNING_TITLE_WIDTH` (68) to **60** so the scrolling title never runs under the spinner. The ETA numeral/label stay bottom-left, far from the corner. +- Adds `run_spinner` to the badge's element-id set; the unified shape-tracker in `ci_status/main.py` already clears on any shape change, so the badge↔quota↔alert seams stay correct. + +## 6. Config + +```toml +[calendar_countdown] +escalation_icons = true # the 5-min / 1-min animated calendar icons +start_animation = "meeting_72x16" # full-panel takeover for the first minute after start; "" disables +start_window_seconds = 60 # how long the start takeover holds (and its urgent-priority window) + +[ci_status] +running_spinner = true # animated 8x8 spinner on the running badge +``` + +All default **on** (operator wants them). `escalation_icons = false` reverts the pre-start stages to today's text-only display; `start_animation = ""` skips the takeover; `running_spinner = false` reverts the badge. + +## 7. Spike results (2026-08-06, live device, fw 1.1.1) + +- **stock_path referencing works.** `AnimationElement` with `stock_path: "shared/.anim"` renders (HTTP 200) for every candidate — also `shared/animations/.anim` and `animations/.anim`; the bare filename 400s. → no asset bundling; reference stock in place. +- **AnimationElement composes with text + rectangles in one draw.** A single draw of `bg` rect + `title`/countdown text + a 16×16 animation icon rendered all three, layered by draw order, with the animation self-looping — the icon-accent approach is sound. Captured proposed layouts for calendar 5-min, calendar 1-min, and the CI running badge with the spinner in two corner positions. +- **Legibility.** The colorful 16×16 calendar icons read clearly beside the amber/red countdown; no clash. The 8×8 spinner fits the badge's top-right corner without touching the ETA numeral (title width reserved). +- **Full-panel takeover renders** at the urgent tier (established: a >current-priority draw wins; `meeting_72x16` lights ~1130/1152 px). + +## 8. Testing + +Pure-logic unit tests (`calendar_countdown` and `ci_status` test files): +- Calendar stage/window → element-set selection: warn adds `cal_icon`=event; imminent adds `cal_icon`=reminder and drops the title; `just_started` (in-progress, elapsed < window) returns the single full-panel `cal_start_anim` at priority 65; in-progress past the window reverts to the "ENDS" set at 20; `start_animation=""` skips the window. +- `select_priority`: `just_started` → 65; other in-progress → 20 (unchanged). +- `escalation_icons=false` / `running_spinner=false` produce today's element sets exactly (regression guard). +- CI running badge: `run_spinner` present when `running_spinner` true; title width is 60 when the spinner is on, 68 when off; quota frames never gain a spinner. + +On-device verification (operator/primary pass): the three calendar moments across a real event's escalation + start; the CI spinner during a real run; each animation composes/reverts cleanly; agents restarted. + +## 9. Open questions / risks + +- **Baked-word takeover:** the default `meeting_72x16` shows "MEETING" for every event start (fine for a work calendar; configurable). No per-event-type mapping (we don't have event-type data) — accepted. +- **Redraw cadence during the takeover:** the calendar polls every `poll_seconds` (10) and shortens near T-0; the animation self-loops on-device between redraws (redraw-continues, established), so the 60 s window animates smoothly with the app re-asserting each poll. +- **Icon vs. title space at warn:** the title scrolls in the narrow `x=18..38` band; if it reads cramped on-device, the fallback is to drop the title at warn too (icon + countdown only) — decided during on-device verification. + +## 10. Rollout + +New branch off `main`; SDD; PR (public, PR-gated). After merge, restart `com.busybar.calendar-countdown` and `com.busybar.ci-status` (the plists are unchanged; a `launchctl kickstart -k` picks up the new code). From c7de0b196dd261a8900c402411b3b6eee58b1afd Mon Sep 17 00:00:00 2001 From: John Osumi <931193+sumitake@users.noreply.github.com> Date: Thu, 6 Aug 2026 23:08:12 -0700 Subject: [PATCH 2/9] config: add stock-animation accent defaults for calendar + ci --- src/busybar/config.py | 7 +++++++ tests/test_config.py | 8 ++++++++ 2 files changed, 15 insertions(+) diff --git a/src/busybar/config.py b/src/busybar/config.py index f8fb690..e139cce 100644 --- a/src/busybar/config.py +++ b/src/busybar/config.py @@ -48,6 +48,12 @@ "imminent_minutes": 1, # <= this (and not in_progress): LED blinks on every draw "chirp": True, # one-time audio chirp exactly at event start (T-0); # set false to disable audio entirely + # Stock-animation accents (2026-08-06). Icons/animation are device + # stock, referenced by stock_path -- no assets bundled. + "escalation_icons": True, # animated calendar icons at warn (5m) / imminent (1m) + "start_animation": "meeting_72x16", # full-panel takeover for the first minute after + # start (aligned with the T-0 chirp); "" disables + "start_window_seconds": 60, # how long the start takeover holds (also its urgent-priority window) }, "ci_status": { "poll_seconds": 120, @@ -71,6 +77,7 @@ # Alert snooze via the device's native start button (v1.5.2) -- see # ci_status/README.md's "Snoozing alerts" section. 0 disables. "snooze_minutes": 30, + "running_spinner": True, # animated 8x8 spinner on the running badge }, } diff --git a/tests/test_config.py b/tests/test_config.py index 835b961..c0fab93 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -112,3 +112,11 @@ def test_device_kwargs_no_warnings_when_all_keys_known(caplog): caplog.set_level(logging.WARNING, logger="busybar.config") device_kwargs({"device": dict(DEFAULTS["device"])}) assert not [r for r in caplog.records if r.levelname == "WARNING"] + +def test_animation_accent_defaults(): + cfg = load_config(path=None) + cal = cfg["calendar_countdown"] + assert cal["escalation_icons"] is True + assert cal["start_animation"] == "meeting_72x16" + assert cal["start_window_seconds"] == 60 + assert cfg["ci_status"]["running_spinner"] is True From 6a8c39ee6aca69916f9a2b04f4303a4c005010ac Mon Sep 17 00:00:00 2001 From: John Osumi <931193+sumitake@users.noreply.github.com> Date: Thu, 6 Aug 2026 23:16:11 -0700 Subject: [PATCH 3/9] calendar: escalation icons, start-takeover animation, just-started priority --- integrations/calendar_countdown/logic.py | 87 ++++++++++++++++++++++-- tests/test_calendar_logic.py | 63 +++++++++++++++++ 2 files changed, 144 insertions(+), 6 deletions(-) diff --git a/integrations/calendar_countdown/logic.py b/integrations/calendar_countdown/logic.py index a62bba1..08d15ef 100644 --- a/integrations/calendar_countdown/logic.py +++ b/integrations/calendar_countdown/logic.py @@ -175,6 +175,23 @@ def _text_width_px(s: str) -> int: SCROLL_RATE = 2000 SCROLL_DELAY_MS = 800 +# --- v1.6 stock-animation accents: escalation icons + start-takeover ------- +# +# ICON_EVENT/ICON_REMINDER are 16x16 stock animations drawn at the panel's +# top-left corner (ICON_X/ICON_Y) during the upcoming path's WARNING state +# (see build_elements): ICON_EVENT while still outside imminent_minutes, +# ICON_REMINDER once inside it (title dropped at that point -- see +# ICON_TITLE_X below). START_ANIM_ID/CAL_ICON_ID are the element ids these +# accents draw under; ICON_TITLE_X is where the title shifts to when an +# icon is present but the title is still shown (leaving x=0..15 clear for +# the 16x16 icon). +ICON_EVENT = "calendar_event_16x16" +ICON_REMINDER = "calendar_reminder_16x16" +ICON_X, ICON_Y = 0, 0 +ICON_TITLE_X = 18 # title shifts right of the 16x16 icon (icon occupies x=0..15) +CAL_ICON_ID = "cal_icon" +START_ANIM_ID = "cal_start_anim" + @dataclass class CalEvent: @@ -404,8 +421,18 @@ def check_threshold_ordering(cfg: dict) -> str | None: return None +def is_just_started(event: CalEvent, now: datetime, in_progress: bool, + start_window_seconds: int, start_animation: str) -> bool: + """True for the first `start_window_seconds` after an event begins, when a + start-takeover animation is configured. The window aligns with the T-0 + chirp and holds the display at urgent priority as a 'running late' alarm.""" + if not in_progress or not start_animation: + return False + return (now - event.start).total_seconds() < start_window_seconds + + def select_priority(minutes_left: float, approach_minutes: int, notice_minutes: int, - in_progress: bool) -> int: + in_progress: bool, just_started: bool = False) -> int: """The draw priority for this poll (v1.5.2 escalation ladder) -- deliberately a SEPARATE ladder from `_state_for`'s visual-palette selection, not a 1:1 mapping of it: the "approach" window changes @@ -415,8 +442,12 @@ def select_priority(minutes_left: float, approach_minutes: int, notice_minutes: persistent alert -- the whole point of this tier) even though they're visually distinct. - - in_progress: PRIORITY_AMBIENT (20) -- see the module-level comment - above for why elevation doesn't apply here. + - just_started (v1.6): PRIORITY_AMBIENT_URGENT (65), checked first -- + the start-takeover window (see is_just_started) is itself a "running + late" alarm and must be able to preempt a persistent alert exactly + like the NOTICE/WARNING tiers below do. + - in_progress (and not just_started): PRIORITY_AMBIENT (20) -- see the + module-level comment above for why elevation doesn't apply here. - <= notice_minutes (covers both NOTICE and WARNING visually): PRIORITY_AMBIENT_URGENT (65) -- strictly above PRIORITY_ALERT, so a persistent CI failure/stuck alert no longer permanently buries an @@ -428,6 +459,8 @@ def select_priority(minutes_left: float, approach_minutes: int, notice_minutes: -- a genuine alert still wins over a merely-approaching event. - otherwise (normal, > approach_minutes): PRIORITY_AMBIENT (20). """ + if just_started: + return PRIORITY_AMBIENT_URGENT if in_progress: return PRIORITY_AMBIENT if minutes_left <= notice_minutes: @@ -601,11 +634,12 @@ def _format_countdown(minutes_left: float) -> str: def build_elements(event: CalEvent, now: datetime, cfg: dict, timeout_s: int, - in_progress: bool) -> list[dict]: + in_progress: bool, just_started: bool = False) -> list[dict]: """Build the v1.4 "airy" Color Horizon layout. `cfg` is the `[calendar_countdown]` config sub-dict (needs - progress_window_minutes, notice_minutes, warn_minutes). `in_progress` + progress_window_minutes, notice_minutes, warn_minutes, and, for the v1.6 + stock-animation accents, escalation_icons/start_animation). `in_progress` selects between the "upcoming" layout (countdown to event.start, a large start-time numeral) and the "in-progress" layout (countdown to event.end, an "ENDS" label, full-width non-draining track fill). No @@ -615,7 +649,21 @@ def build_elements(event: CalEvent, now: datetime, cfg: dict, timeout_s: int, from `minutes_left` each poll -- not the native `countdown` element (see the CD_TEXT_X comment above for why). Draw order below is z-order, first = behind. + + `just_started` (v1.6, default False) short-circuits everything above: a + full-panel takeover animation (see is_just_started) replaces the normal + layout entirely for the start-takeover window, so it's checked first and + returns before any of the upcoming/in-progress element-building below. """ + if just_started: + bg = {"id": "bg", "type": "rectangle", "x": 0, "y": 0, + "width": PANEL_WIDTH, "height": PANEL_HEIGHT, "fill": "gradient_v", + "fill_colors": BG_GRADIENT[STATE_IN_PROGRESS], "border_width": 0, "timeout": timeout_s} + anim = {"id": START_ANIM_ID, "type": "animation", + "stock_path": f"shared/{cfg['start_animation']}.anim", + "x": 0, "y": 0, "loop": True, "timeout": timeout_s} + return [bg, anim] + # Uppercase kills descenders (g, y, p, ...), which is what let the title # collide with the track below it before the ink-offset fix -- see the # geometry comment above TITLE_Y. @@ -656,6 +704,26 @@ def build_elements(event: CalEvent, now: datetime, cfg: dict, timeout_s: int, "scroll_repeat_delay": SCROLL_DELAY_MS, }) + # v1.6 escalation icons: a 16x16 stock animation in the WARNING state + # (upcoming path only -- in_progress never reaches this state visually + # the same way, see _state_for), swapping from the plain event icon to + # the reminder icon once inside imminent_minutes. At imminent, the + # title is dropped entirely (icon + big countdown number only); before + # that, the title just shifts right of the icon (ICON_TITLE_X) with a + # narrowed width so it still scrolls in the remaining gap. + icon_element = None + if not in_progress and cfg.get("escalation_icons") and state == STATE_WARNING: + imminent = minutes_left <= cfg["imminent_minutes"] + icon_name = ICON_REMINDER if imminent else ICON_EVENT + icon_element = {"id": CAL_ICON_ID, "type": "animation", + "stock_path": f"shared/{icon_name}.anim", + "x": ICON_X, "y": ICON_Y, "loop": True, "timeout": timeout_s} + if imminent: + title_element = None # drop the title at imminent -> icon + big number + else: + title_element.update({"x": ICON_TITLE_X, + "width": CD_TEXT_X - ICON_TITLE_X - 2}) # scroll in the gap + track_element = { "id": "track", "type": "rectangle", @@ -689,7 +757,11 @@ def build_elements(event: CalEvent, now: datetime, cfg: dict, timeout_s: int, **track_fill, } - elements = [bg_element, title_element, track_element, track_fill_element] + elements = [bg_element] + if title_element is not None: + elements.append(title_element) + elements.append(track_element) + elements.append(track_fill_element) if in_progress: elements.append({ @@ -738,4 +810,7 @@ def build_elements(event: CalEvent, now: datetime, cfg: dict, timeout_s: int, "timeout": timeout_s, }) + if icon_element is not None: + elements.append(icon_element) + return elements diff --git a/tests/test_calendar_logic.py b/tests/test_calendar_logic.py index 9fd5e4e..53d7c68 100644 --- a/tests/test_calendar_logic.py +++ b/tests/test_calendar_logic.py @@ -15,6 +15,7 @@ _minutes_left, select_priority, select_led, resolve_led_value, should_chirp, commit_chirped, next_sleep_seconds, IMMINENT_LED_COLOR, CHIRP_STOCK_PATH, _chirp_key, check_threshold_ordering, + is_just_started, START_ANIM_ID, CAL_ICON_ID, ICON_EVENT, ICON_REMINDER, ) from busybar.display import ( PRIORITY_AMBIENT, PRIORITY_AMBIENT_RAISED, PRIORITY_AMBIENT_URGENT, @@ -685,3 +686,65 @@ def test_check_threshold_ordering_missing_keys_returns_none(): # An old-style cfg dict predating the v1.5.2 keys -- nothing to check, # must not KeyError. assert check_threshold_ordering({"notice_minutes": 15, "warn_minutes": 5}) is None + + +# --- v1.6 stock-animation accents: is_just_started, priority, icons, takeover ---- +# +# NOW2 (not the module's own `NOW`) is deliberately a distinct local +# constant here -- reassigning the module-level `NOW` at this point in the +# file would retroactively change the reference time every test ABOVE this +# point sees at call time (Python resolves a function's globals when it +# RUNS, not when it's defined), since pytest collects and runs the whole +# module in one process. `_ev`/`_cfg` are local helpers distinct from the +# top-of-file `ev`/`CFG` for the same reason -- this section is self- +# contained and must not perturb anything above it. + +def _ev(start): # 30-min event + return CalEvent(title="Standup", start=start, end=start + timedelta(minutes=30), all_day=False) + +def _cfg(**over): + base = {"poll_seconds": 10, "lookahead_hours": 12, "warn_minutes": 5, "notice_minutes": 15, + "approach_minutes": 30, "imminent_minutes": 1, "progress_window_minutes": 60, + "escalation_icons": True, "start_animation": "meeting_72x16", "start_window_seconds": 60} + base.update(over); return base + +NOW2 = datetime(2026, 8, 6, 12, 0, tzinfo=timezone.utc) + +def test_is_just_started_window(): + ev = _ev(NOW2 - timedelta(seconds=30)) # started 30s ago + assert is_just_started(ev, NOW2, True, 60, "meeting_72x16") is True + ev2 = _ev(NOW2 - timedelta(seconds=90)) # started 90s ago + assert is_just_started(ev2, NOW2, True, 60, "meeting_72x16") is False + assert is_just_started(ev, NOW2, True, 60, "") is False # disabled + assert is_just_started(ev, NOW2, False, 60, "meeting_72x16") is False # not in progress + +def test_priority_just_started_is_urgent(): + assert select_priority(0.0, 30, 15, True, just_started=True) == PRIORITY_AMBIENT_URGENT + assert select_priority(0.0, 30, 15, True, just_started=False) == PRIORITY_AMBIENT # unchanged + +def test_warn_stage_adds_event_icon(): + ev = _ev(NOW2 + timedelta(minutes=4)) # 4m out -> warn, > imminent + els = build_elements(ev, NOW2, _cfg(), 15, in_progress=False) + icon = next(e for e in els if e["id"] == CAL_ICON_ID) + assert icon["type"] == "animation" and icon["stock_path"] == f"shared/{ICON_EVENT}.anim" + assert icon["x"] == 0 and icon["y"] == 0 + assert any(e["id"] == "title" for e in els) # title still present at warn + +def test_imminent_stage_uses_reminder_icon_and_drops_title(): + ev = _ev(NOW2 + timedelta(seconds=30)) # 0.5m out -> imminent + els = build_elements(ev, NOW2, _cfg(), 15, in_progress=False) + icon = next(e for e in els if e["id"] == CAL_ICON_ID) + assert icon["stock_path"] == f"shared/{ICON_REMINDER}.anim" + assert not any(e["id"] == "title" for e in els) # title dropped at imminent + +def test_just_started_returns_takeover_animation(): + ev = _ev(NOW2 - timedelta(seconds=10)) + els = build_elements(ev, NOW2, _cfg(), 15, in_progress=True, just_started=True) + anim = next(e for e in els if e["id"] == START_ANIM_ID) + assert anim["type"] == "animation" and anim["stock_path"] == "shared/meeting_72x16.anim" + assert not any(e["id"] in ("cd_text", "ends") for e in els) # takeover replaces the countdown + +def test_escalation_icons_off_is_unchanged(): + ev = _ev(NOW2 + timedelta(minutes=4)) + els = build_elements(ev, NOW2, _cfg(escalation_icons=False), 15, in_progress=False) + assert not any(e["id"] == CAL_ICON_ID for e in els) From bad84b78a5a3bd263166cd5902a35187d479a643 Mon Sep 17 00:00:00 2001 From: John Osumi <931193+sumitake@users.noreply.github.com> Date: Thu, 6 Aug 2026 23:25:14 -0700 Subject: [PATCH 4/9] calendar: drop occluded time element when escalation icon is present Review fix: the 16x16 escalation icon at (0,0) was drawn over the still-unconditional `time` element (x=2,y=5), occluding its leading digits. Drop `time` whenever the icon is shown (warn + imminent), same as the title is already dropped at imminent. Also documents imminent_minutes in build_elements' cfg docstring (minor). --- integrations/calendar_countdown/logic.py | 20 +++++++++++++++++--- tests/test_calendar_logic.py | 19 +++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/integrations/calendar_countdown/logic.py b/integrations/calendar_countdown/logic.py index 08d15ef..7221aee 100644 --- a/integrations/calendar_countdown/logic.py +++ b/integrations/calendar_countdown/logic.py @@ -639,7 +639,10 @@ def build_elements(event: CalEvent, now: datetime, cfg: dict, timeout_s: int, `cfg` is the `[calendar_countdown]` config sub-dict (needs progress_window_minutes, notice_minutes, warn_minutes, and, for the v1.6 - stock-animation accents, escalation_icons/start_animation). `in_progress` + stock-animation accents, escalation_icons/start_animation/ + imminent_minutes -- the icon block reads cfg["imminent_minutes"] + directly, so it's a hard requirement whenever escalation_icons is on). + `in_progress` selects between the "upcoming" layout (countdown to event.start, a large start-time numeral) and the "in-progress" layout (countdown to event.end, an "ENDS" label, full-width non-draining track fill). No @@ -710,7 +713,12 @@ def build_elements(event: CalEvent, now: datetime, cfg: dict, timeout_s: int, # the reminder icon once inside imminent_minutes. At imminent, the # title is dropped entirely (icon + big countdown number only); before # that, the title just shifts right of the icon (ICON_TITLE_X) with a - # narrowed width so it still scrolls in the remaining gap. + # narrowed width so it still scrolls in the remaining gap. The `time` + # element (start-time text at TIME_X=2/TIME_Y=5) is ALSO dropped + # whenever the icon is present -- see the `elif icon_element is None` + # branch below -- since it sits under the icon's 16x16 footprint and + # would otherwise have its leading digits occluded; this applies to + # both the warn and imminent sub-stages, not just imminent. icon_element = None if not in_progress and cfg.get("escalation_icons") and state == STATE_WARNING: imminent = minutes_left <= cfg["imminent_minutes"] @@ -774,7 +782,13 @@ def build_elements(event: CalEvent, now: datetime, cfg: dict, timeout_s: int, "y": ENDS_Y, "timeout": timeout_s, }) - else: + elif icon_element is None: + # `time` sits at TIME_X=2, TIME_Y=5 (ink rows 7-15), which overlaps + # the escalation icon's 16x16 footprint (x=0..15, y=0..15) -- drop + # it whenever the icon is present (both the warn and imminent + # sub-stages) rather than let the icon occlude its leading digits. + # `cd_text` at CD_TEXT_X=39 already clears the icon and remains the + # sole "how much time" readout in that case. elements.append({ "id": "time", "type": "text", diff --git a/tests/test_calendar_logic.py b/tests/test_calendar_logic.py index 53d7c68..b49fbdc 100644 --- a/tests/test_calendar_logic.py +++ b/tests/test_calendar_logic.py @@ -730,6 +730,25 @@ def test_warn_stage_adds_event_icon(): assert icon["x"] == 0 and icon["y"] == 0 assert any(e["id"] == "title" for e in els) # title still present at warn +def test_icon_present_drops_time_element(): + # The icon is 16x16 at (0,0) -- it sits directly on top of the `time` + # element (start-time text at TIME_X=2/TIME_Y=5), so `time` must be + # excluded whenever the icon shows, in both the warn (icon still + # ICON_EVENT, title present) and imminent (icon ICON_REMINDER, title + # dropped) sub-stages. cd_text (x=39) already clears the icon and + # stays present as the sole countdown readout. + warn_ev = _ev(NOW2 + timedelta(minutes=4)) # 4m out -> warn, > imminent + els = build_elements(warn_ev, NOW2, _cfg(), 15, in_progress=False) + assert not any(e["id"] == "time" for e in els) + assert any(e["id"] == CAL_ICON_ID for e in els) + assert any(e["id"] == "cd_text" for e in els) + + imminent_ev = _ev(NOW2 + timedelta(seconds=30)) # 0.5m out -> imminent + els = build_elements(imminent_ev, NOW2, _cfg(), 15, in_progress=False) + assert not any(e["id"] == "time" for e in els) + assert any(e["id"] == CAL_ICON_ID for e in els) + assert any(e["id"] == "cd_text" for e in els) + def test_imminent_stage_uses_reminder_icon_and_drops_title(): ev = _ev(NOW2 + timedelta(seconds=30)) # 0.5m out -> imminent els = build_elements(ev, NOW2, _cfg(), 15, in_progress=False) From 7eaf76ccd5c2e14855e90f6e548eed09d3e292cb Mon Sep 17 00:00:00 2001 From: John Osumi <931193+sumitake@users.noreply.github.com> Date: Thu, 6 Aug 2026 23:36:20 -0700 Subject: [PATCH 5/9] calendar: draw start-takeover at urgent; unified shape-tracker clear --- integrations/calendar_countdown/main.py | 109 +++++++++++++++--------- tests/test_calendar_loop.py | 82 ++++++++++++++++-- 2 files changed, 142 insertions(+), 49 deletions(-) diff --git a/integrations/calendar_countdown/main.py b/integrations/calendar_countdown/main.py index b83a7fb..e82ab8c 100644 --- a/integrations/calendar_countdown/main.py +++ b/integrations/calendar_countdown/main.py @@ -18,7 +18,7 @@ from .logic import (ascii_safe, build_elements, select_active_event, select_next_event, _minutes_left, select_priority, select_led, resolve_led_value, LED_OFF_ELEMENTS, LED_OFF_COLOR, - should_chirp, commit_chirped, + should_chirp, commit_chirped, is_just_started, next_sleep_seconds, CHIRP_STOCK_PATH, check_threshold_ordering) APP = "calendar_countdown" @@ -29,36 +29,46 @@ def run_once(client, fetch, cfg: dict, now: datetime, dry_run: bool, state: dict | None = None) -> str: """Run one poll cycle. `state`, when passed, is a caller-owned dict this - function uses to remember the previous draw's `in_progress` value - across calls (main() passes one shared dict across loop iterations; - tests calling run_once standalone can omit it), plus (v1.5.2) the - next known event's start time (`next_start`, for the T-0 sleep- - shortening in main()'s loop), the chirp edge-detection bookkeeping - (`seen_upcoming`/`chirped`, maintained by should_chirp/commit_chirped), - and `led_on` -- whether the LED is believed to currently be lit, - committed only after a confirmed successful send (see - resolve_led_value's docstring). This last one matters on EVERY path - that can draw or otherwise signal the device, including the "no - upcoming event" path below: an event that vanishes without ever - passing through `in_progress=True` (filtered out, or shorter than one - poll interval) must still resolve its LED to an explicit off, not - silently strand it lit. See calendar_countdown.logic for the full - escalation-ladder, LED, and chirp design. + function uses to remember the last drawn element-id set (`last_shape`, + v1.6 -- see below) across calls (main() passes one shared dict across + loop iterations; tests calling run_once standalone can omit it), plus + (v1.5.2) the next known event's start time (`next_start`, for the T-0 + sleep-shortening in main()'s loop), the chirp edge-detection + bookkeeping (`seen_upcoming`/`chirped`, maintained by + should_chirp/commit_chirped), and `led_on` -- whether the LED is + believed to currently be lit, committed only after a confirmed + successful send (see resolve_led_value's docstring). This last one + matters on EVERY path that can draw or otherwise signal the device, + including the "no upcoming event" path below: an event that vanishes + without ever passing through `in_progress=True` (filtered out, or + shorter than one poll interval) must still resolve its LED to an + explicit off, not silently strand it lit. See calendar_countdown.logic + for the full escalation-ladder, LED, chirp, and start-takeover design. - The upcoming and in-progress layouts use different element id sets - (`time` vs `ends`) and the device's draw endpoint upserts by id rather + The upcoming, in-progress, and (v1.6) start-takeover layouts each use a + different element id set (`time` vs `ends` vs the takeover's `bg`+ + `cal_start_anim` alone -- and the upcoming layout's own id set already + varies further with the escalation-icon sub-states, see + build_elements) and the device's draw endpoint upserts by id rather than replacing an app's whole element set -- confirmed on-device that switching id sets without an explicit clear leaves the previous set's elements rendered on top of the new ones until their own timeout expires (originally found with the v1.3 `time_card`+`time` vs `ends` id sets; the same upsert-by-id model applies regardless of which ids - are in play). `state` lets us clear only at the transition, not on - every poll. Priority changes (v1.5.2's escalation ladder) do NOT need - this same clear-on-change treatment: they're the same app_name - upserting the same element ids at a new priority number, not a shape - change -- see busybar.display's PRIORITY_AMBIENT_URGENT docstring for - why a strictly-higher same-app_name draw always succeeds regardless - of priority. + are in play). `state["last_shape"]` (v1.6 -- replaces the earlier + boolean `state["in_progress"]` transition check, which only caught the + upcoming<->in-progress edge and missed every other id-set change the + escalation icons and start-takeover introduce) is a frozenset of the + ids in the most recently DRAWN payload; comparing it against the ids + about to be drawn THIS poll lets run_once clear only when the id set + actually changed, not on every poll -- mirrors ci_status's own unified + shape tracker (see ci_status.main.run_once's docstring). Priority + changes (v1.5.2's escalation ladder) do NOT need this same + clear-on-change treatment: they're the same app_name upserting the + same element ids at a new priority number, not a shape change -- see + busybar.display's PRIORITY_AMBIENT_URGENT docstring for why a + strictly-higher same-app_name draw always succeeds regardless of + priority. """ c = cfg["calendar_countdown"] timeout_s = ambient_timeout(c["poll_seconds"]) @@ -99,7 +109,7 @@ def run_once(client, fetch, cfg: dict, now: datetime, dry_run: bool, # off-transition rather than assuming it landed. client.clear(APP) if state is not None: - state["in_progress"] = None + state["last_shape"] = None # device is now genuinely blank state["next_start"] = None return "no upcoming event; cleared" @@ -140,24 +150,41 @@ def run_once(client, fetch, cfg: dict, now: datetime, dry_run: bool, # (still in_progress, same event) retries rather than # silently skipping the chirp forever. - if state is not None and state.get("in_progress") not in (None, in_progress): - # clear()'s own success/failure is intentionally not checked here -- - # only draw()'s result (below) gates whether `state` commits. If - # clear() silently fails but draw() then succeeds, the new element - # set is still correctly installed via the id-upsert; any leftover - # stale ids from before the failed clear are bounded by their own - # original timeout, a one-off gap that self-heals, not a reason to - # re-clear on every subsequent poll. Gating on clear() too would mean - # a persistently-failing clear() retries forever even once draw() - # keeps succeeding, since `state` would never converge. - client.clear(APP) - - elements = build_elements(event, now, c, timeout_s, in_progress) + # v1.6 start-takeover: True for the first start_window_seconds after an + # event begins (see is_just_started's docstring) -- holds the display + # at PRIORITY_AMBIENT_URGENT and swaps in the full-panel takeover + # animation, threaded into both build_elements and select_priority below. + just_started = is_just_started(event, now, in_progress, + c["start_window_seconds"], c["start_animation"]) + elements = build_elements(event, now, c, timeout_s, in_progress, just_started=just_started) minutes_left = _minutes_left(event, now, in_progress) - priority = select_priority(minutes_left, c["approach_minutes"], c["notice_minutes"], in_progress) + priority = select_priority(minutes_left, c["approach_minutes"], c["notice_minutes"], + in_progress, just_started=just_started) led_should_be_on = select_led(minutes_left, c["imminent_minutes"], in_progress) led_was_on = state.get("led_on", False) if state is not None else False led = resolve_led_value(led_should_be_on, led_was_on) + + # Unified shape-tracker clear (v1.6, replaces the old boolean + # state["in_progress"]-transition check -- see this function's + # docstring for why that check alone can no longer catch every id-set + # change once escalation icons and the start-takeover are in play). + # `new_shape` must be computed from THIS poll's elements before the + # draw call below. + new_shape = frozenset(e["id"] for e in elements) + if state is not None: + last_shape = state.get("last_shape") + if last_shape is not None and last_shape != new_shape: + # clear()'s own success/failure is intentionally not checked here -- + # only draw()'s result (below) gates whether `state` commits. If + # clear() silently fails but draw() then succeeds, the new element + # set is still correctly installed via the id-upsert; any leftover + # stale ids from before the failed clear are bounded by their own + # original timeout, a one-off gap that self-heals, not a reason to + # re-clear on every subsequent poll. Gating on clear() too would mean + # a persistently-failing clear() retries forever even once draw() + # keeps succeeding, since `state` would never converge. + client.clear(APP) + result = client.draw(APP, elements=elements, priority=priority, led_notification_color=led) if state is not None and result == DrawResult.DRAWN: # Only commit the transition once it actually lands on the device. @@ -167,7 +194,7 @@ def run_once(client, fetch, cfg: dict, now: datetime, dry_run: bool, # happened that never actually reached the device -- which would # otherwise let stale elements from the old layout persist # unbounded (no further poll would ever re-attempt the clear). - state["in_progress"] = in_progress + state["last_shape"] = new_shape # Same discipline for the LED: only believe it's in the intended # state once this exact draw (carrying that exact led value) is # confirmed to have landed. diff --git a/tests/test_calendar_loop.py b/tests/test_calendar_loop.py index caec113..552d702 100644 --- a/tests/test_calendar_loop.py +++ b/tests/test_calendar_loop.py @@ -18,7 +18,14 @@ "auto_busy": False, "calendars": [], # v1.5.2 escalation ladder + chirp "approach_minutes": 30, "imminent_minutes": 1, - "chirp": True}} + "chirp": True, + # v1.6 stock-animation accents: run_once reads + # these unconditionally (is_just_started), so + # every fixture using CFG needs them present -- + # same defaults as busybar.config.DEFAULTS and + # test_calendar_logic.py's own cfg. + "start_animation": "meeting_72x16", + "start_window_seconds": 60}} def make_event(offset_min: int, dur_min: int = 30, title: str = "Standup") -> CalEvent: @@ -87,7 +94,11 @@ def test_no_clear_on_first_draw_with_fresh_state(): state = {} run_once(client, lambda hours: [make_event(23)], CFG, NOW, dry_run=False, state=state) client.clear.assert_not_called() - assert state["in_progress"] is False + # v1.6: state["in_progress"] was replaced by the unified shape tracker + # (state["last_shape"]) -- see main.run_once's docstring. The upcoming + # layout's id set (no icon: CFG has no "escalation_icons" key). + assert state["last_shape"] == frozenset( + {"bg", "title", "track", "track_fill", "time", "divider", "cd_text"}) def test_no_clear_across_polls_with_same_state(): client = Mock() @@ -105,7 +116,9 @@ def test_clears_on_upcoming_to_in_progress_transition(): active = make_event(-5, dur_min=30, title="Active") run_once(client, lambda hours: [active], CFG, NOW, dry_run=False, state=state) client.clear.assert_called_once_with("calendar_countdown") - assert state["in_progress"] is True + # v1.6: the in-progress layout's id set ("ends" instead of "time"). + assert state["last_shape"] == frozenset( + {"bg", "title", "track", "track_fill", "ends", "divider", "cd_text"}) def test_clears_on_in_progress_to_upcoming_transition(): client = Mock() @@ -135,15 +148,17 @@ def test_failed_draw_leaves_state_unchanged_and_retries_next_poll(): client = Mock() client.draw.return_value = DrawResult.DRAWN state = {} + upcoming_shape = frozenset({"bg", "title", "track", "track_fill", "time", "divider", "cd_text"}) + in_progress_shape = frozenset({"bg", "title", "track", "track_fill", "ends", "divider", "cd_text"}) run_once(client, lambda hours: [make_event(23)], CFG, NOW, dry_run=False, state=state) - assert state["in_progress"] is False + assert state["last_shape"] == upcoming_shape active = make_event(-5, dur_min=30, title="Active") fetch = lambda hours: [active] client.draw.return_value = DrawResult.UNREACHABLE run_once(client, fetch, CFG, NOW, dry_run=False, state=state) - assert state["in_progress"] is False # unchanged: draw never landed + assert state["last_shape"] == upcoming_shape # unchanged: draw never landed assert client.clear.call_count == 1 # transition was still detected and clear attempted assert client.draw.call_count == 2 @@ -153,7 +168,7 @@ def test_failed_draw_leaves_state_unchanged_and_retries_next_poll(): run_once(client, fetch, CFG, NOW, dry_run=False, state=state) assert client.clear.call_count == 2 assert client.draw.call_count == 3 - assert state["in_progress"] is True + assert state["last_shape"] == in_progress_shape def test_clear_failure_does_not_block_state_commit_when_draw_succeeds(): # (b) clear()'s own return value is intentionally ignored -- only @@ -171,7 +186,8 @@ def test_clear_failure_does_not_block_state_commit_when_draw_succeeds(): active = make_event(-5, dur_min=30, title="Active") run_once(client, lambda hours: [active], CFG, NOW, dry_run=False, state=state) client.clear.assert_called_once_with("calendar_countdown") - assert state["in_progress"] is True + assert state["last_shape"] == frozenset( + {"bg", "title", "track", "track_fill", "ends", "divider", "cd_text"}) def test_state_reset_after_no_event_clear(): client = Mock() @@ -179,7 +195,7 @@ def test_state_reset_after_no_event_clear(): state = {} run_once(client, lambda hours: [make_event(23)], CFG, NOW, dry_run=False, state=state) run_once(client, lambda hours: [], CFG, NOW, dry_run=False, state=state) # clears, resets state - assert state["in_progress"] is None + assert state["last_shape"] is None client.clear.reset_mock() run_once(client, lambda hours: [make_event(23)], CFG, NOW, dry_run=False, state=state) # No stale elements remain after the "no event" clear -- no extra clear needed. @@ -462,3 +478,53 @@ def test_run_once_next_start_none_when_no_event(): state: dict = {} run_once(client, lambda hours: [], CFG, NOW, dry_run=False, state=state) assert state["next_start"] is None + + +# --- v1.6 stock-animation accents: just_started takeover + shape-tracker clear --- +# +# NOTE: this block's own `NOW` fixture is named `TAKEOVER_NOW`, not `NOW` -- +# the module already defines `NOW` above (2026-08-03 13:37 UTC, read by +# every earlier test via make_event/CFG at call time), so reusing that name +# here would silently reassign it at import time and change every earlier +# test's clock. + +from busybar.client import DrawResult +from busybar.display import PRIORITY_AMBIENT_URGENT +from integrations.calendar_countdown.main import run_once +from integrations.calendar_countdown.logic import CalEvent, START_ANIM_ID + +class FakeClient: + def __init__(self): self.draws=[]; self.clears=0 + def draw(self, app, elements, priority=50, led_notification_color=None): + self.draws.append((elements, priority)); return DrawResult.DRAWN + def clear(self, app): self.clears += 1; return True + def get_busy(self): return {} + def play_audio(self, *a, **k): return True + def set_busy_simple(self, *a, **k): return True + +TAKEOVER_NOW = datetime(2026, 8, 6, 12, 0, tzinfo=timezone.utc) +def _cfg(**o): + b={"poll_seconds":10,"lookahead_hours":12,"warn_minutes":5,"notice_minutes":15, + "approach_minutes":30,"imminent_minutes":1,"progress_window_minutes":60,"include_all_day":False, + "auto_busy":False,"calendars":[],"chirp":False,"escalation_icons":True, + "start_animation":"meeting_72x16","start_window_seconds":60} + b.update(o); return {"calendar_countdown": b} +def _fetch(ev): # fetch(lookahead) -> [ev] + return lambda hours: [ev] + +def test_just_started_draws_takeover_at_urgent(): + ev = CalEvent("Standup", TAKEOVER_NOW - timedelta(seconds=15), TAKEOVER_NOW + timedelta(minutes=29), False) + c = FakeClient(); st = {} + run_once(c, _fetch(ev), _cfg(), TAKEOVER_NOW, dry_run=False, state=st) + elements, priority = c.draws[-1] + assert priority == PRIORITY_AMBIENT_URGENT + assert any(e["id"] == START_ANIM_ID for e in elements) + +def test_shape_change_triggers_clear(): + # First poll: warn stage (icon+title). Second poll: takeover (different id-set) -> clear. + ev = CalEvent("Standup", TAKEOVER_NOW + timedelta(minutes=4), TAKEOVER_NOW + timedelta(minutes=34), False) + c = FakeClient(); st = {} + run_once(c, _fetch(ev), _cfg(), TAKEOVER_NOW, dry_run=False, state=st) # warn + ev2 = CalEvent("Standup", TAKEOVER_NOW - timedelta(seconds=10), TAKEOVER_NOW + timedelta(minutes=29), False) + run_once(c, _fetch(ev2), _cfg(), TAKEOVER_NOW, dry_run=False, state=st) # takeover + assert c.clears >= 1 # id-set changed -> cleared before the takeover draw From 86cb498015b96ff851f61fd7dc22835c0fb6663d Mon Sep 17 00:00:00 2001 From: John Osumi <931193+sumitake@users.noreply.github.com> Date: Thu, 6 Aug 2026 23:46:06 -0700 Subject: [PATCH 6/9] ci: animated spinner on the running badge (config-gated) --- integrations/ci_status/logic.py | 37 ++++++++++++++++++++++++++++----- integrations/ci_status/main.py | 3 ++- tests/test_ci_logic.py | 35 ++++++++++++++++++++++++++++++- 3 files changed, 68 insertions(+), 7 deletions(-) diff --git a/integrations/ci_status/logic.py b/integrations/ci_status/logic.py index c2fbd95..5fc3c1b 100644 --- a/integrations/ci_status/logic.py +++ b/integrations/ci_status/logic.py @@ -189,6 +189,16 @@ def evaluate_runs(repo: str, runs: list[dict], now: datetime, RUNNING_NUMERAL_X = OVERLAY_TITLE_X # the running badge's single numeral sits at the # same left margin as the title/quota "pct" numeral +# Running-badge spinner (v1.6, config-gated via cfg["ci_status"]["running_spinner"]): +# an animated 8x8 stock asset in the panel's top-right corner. RUNNING_TITLE_WIDTH_SPINNER +# reserves that corner from the scrolling title so it never runs underneath the spinner -- +# used in place of RUNNING_TITLE_WIDTH, in both the title element's own width and the +# _title_fits scroll-decision call, whenever show_spinner is on (quota frames never get a +# spinner, so OVERLAY_TITLE_WIDTH there is untouched). +RUN_SPINNER_ID = "run_spinner" +SPINNER_STOCK = "shared/spinner_front_8x8.anim" +RUNNING_TITLE_WIDTH_SPINNER = 60 # reserve the top-right 8x8 corner (spinner at x=64) + # --- running-job badge ----------------------------------------------------------- @@ -352,17 +362,25 @@ def _build_running_title(run: dict, repo: str, other_count: int) -> str: return ascii_safe(text).upper() -def _build_running_elements(info: RunningInfo, timeout_s: int) -> list[dict]: +def _build_running_elements(info: RunningInfo, timeout_s: int, show_spinner: bool = False) -> list[dict]: """v1.4-language badge: gradient bg, title ribbon (scrolls if it doesn't fit), full-width horizon-line track repurposed as elapsed/ median progress, and a large ETA numeral -- no card surfaces, no native countdown element, same design lineage as the v1.4 calendar layout. Draw order is z-order, first = behind. + + `show_spinner` (v1.6, config-gated): when true, reserves the panel's + top-right 8x8 corner from the title ribbon (RUNNING_TITLE_WIDTH_SPINNER + in place of RUNNING_TITLE_WIDTH, for both the element's own width and + the scroll-fit decision, so the two stay consistent) and appends an + animated spinner element there. Defaults to False so existing callers + are unaffected. """ title_text = _build_running_title(info.run, info.repo, info.other_count) eta_text = _format_eta_text(info.run, info.median_minutes, info.now) elapsed = _elapsed_minutes(info.run, info.now) track_width = _progress_width(elapsed, info.median_minutes) + title_width = RUNNING_TITLE_WIDTH_SPINNER if show_spinner else RUNNING_TITLE_WIDTH bg_element = { "id": "bg", "type": "rectangle", "x": 0, "y": 0, @@ -373,9 +391,9 @@ def _build_running_elements(info: RunningInfo, timeout_s: int) -> list[dict]: title_element = { "id": "title", "type": "text", "text": title_text, "font": "small", "color": RUNNING_TITLE_COLOR, "x": RUNNING_TITLE_X, "y": RUNNING_TITLE_Y, - "width": RUNNING_TITLE_WIDTH, "timeout": timeout_s, + "width": title_width, "timeout": timeout_s, } - if not _title_fits(title_text, RUNNING_TITLE_WIDTH): + if not _title_fits(title_text, title_width): title_element.update({ "scroll_rate": SCROLL_RATE, "scroll_start_delay": SCROLL_DELAY_MS, @@ -414,6 +432,10 @@ def _build_running_elements(info: RunningInfo, timeout_s: int) -> list[dict]: "x": RUNNING_NUMERAL_X + _text_width_px(eta_text) + RUNNING_LABEL_GAP_PX, "y": RUNNING_LABEL_Y, "timeout": timeout_s, }) + + if show_spinner: + elements.append({"id": RUN_SPINNER_ID, "type": "animation", "stock_path": SPINNER_STOCK, + "x": 64, "y": 0, "loop": True, "timeout": timeout_s}) return elements @@ -619,7 +641,8 @@ def overlay_frame_sequence(show_quota: bool) -> list[str]: def build_overlay_payload(frame_name: str, timeout_s: int, *, running: RunningInfo | None = None, - quota_by_bucket: dict[str, QuotaInfo] | None = None) -> dict | None: + quota_by_bucket: dict[str, QuotaInfo] | None = None, + show_spinner: bool = False) -> dict | None: """Build the {"elements", "priority", "led"} payload for one overlay- tier dwell slot, or `None` if this frame's data isn't available this cycle -- the caller must treat `None` as "skip this dwell slot @@ -628,11 +651,15 @@ def build_overlay_payload(frame_name: str, timeout_s: int, *, quota frame from rotation for a cycle instead of crashing or showing minutes-old numbers (see main.py's 5-minute staleness check, which is what actually keeps `quota_by_bucket` fresh enough to trust here). + + `show_spinner` (v1.6) is threaded through ONLY on the CI-badge branch + -- quota frames never get a spinner, regardless of this flag. Defaults + to False so existing callers are unaffected. """ if frame_name == OVERLAY_FRAME_CI_BADGE: if running is None: return None - return {"elements": _build_running_elements(running, timeout_s), + return {"elements": _build_running_elements(running, timeout_s, show_spinner=show_spinner), "priority": PRIORITY_OVERLAY, "led": None} if frame_name in (OVERLAY_FRAME_QUOTA_GQL, OVERLAY_FRAME_QUOTA_REST): bucket_key = "graphql" if frame_name == OVERLAY_FRAME_QUOTA_GQL else "core" diff --git a/integrations/ci_status/main.py b/integrations/ci_status/main.py index c06504e..ab5e64a 100644 --- a/integrations/ci_status/main.py +++ b/integrations/ci_status/main.py @@ -263,7 +263,8 @@ class the v1.3.1 calendar transition-clear fix addressed, recurring at frame_name = sequence[frame_index] overlay_payload = build_overlay_payload( frame_name, OVERLAY_DWELL_SECONDS, - running=running_info, quota_by_bucket=quota_by_bucket) + running=running_info, quota_by_bucket=quota_by_bucket, + show_spinner=c.get("running_spinner", False)) if overlay_payload is None: # This frame's data wasn't available this cycle (e.g. a # quota frame with no fresh rate_limit data). Advance diff --git a/tests/test_ci_logic.py b/tests/test_ci_logic.py index 4384c81..bb98b87 100644 --- a/tests/test_ci_logic.py +++ b/tests/test_ci_logic.py @@ -12,7 +12,7 @@ _format_eta_text, _progress_width, _build_running_title, parse_rate_limit, _quota_headroom, _quota_used_width, resolve_repo_list, _eta_label, RUNNING_NUMERAL_X, RUNNING_LABEL_GAP_PX, - compute_alert_fingerprint, update_snooze, + compute_alert_fingerprint, update_snooze, RUN_SPINNER_ID, ) from busybar.display import PRIORITY_OVERLAY, OVERLAY_DWELL_SECONDS, PRIORITY_ALERT from calendar_countdown.logic import _text_width_px @@ -817,3 +817,36 @@ def test_update_snooze_session_starting_mid_alert_after_continuous_polling(): assert update_snooze(FP_A, False, t1, 30, state) == (False, False) # still no session t2 = t1 + timedelta(seconds=10) assert update_snooze(FP_A, True, t2, 30, state) == (False, True) # session starts -- pending + + +# --- CI running-badge spinner (v1.6, task 4) --------------------------------------- +# +# NOTE: uses SPINNER_NOW (not the module-level NOW) -- a distinct name is +# used deliberately here rather than reassigning NOW, since NOW is read at +# call time (late-bound) by dozens of test functions throughout this file; +# rebinding it at module scope after this point would silently change the +# "now" every earlier-defined test observes when pytest actually calls them. + +SPINNER_NOW = datetime(2026, 8, 6, 12, 0, tzinfo=timezone.utc) + + +def _running(): + return RunningInfo(run={"name": "build", "run_started_at": "2026-08-06T11:58:00Z", + "pull_requests": [{"number": 42}], "workflow_id": 1}, + repo="me/repo", other_count=0, median_minutes=8.0, now=SPINNER_NOW) + + +def test_spinner_present_and_title_reserved_when_on(): + p = build_overlay_payload(OVERLAY_FRAME_CI_BADGE, 10, running=_running(), show_spinner=True) + els = p["elements"] + spin = next(e for e in els if e["id"] == RUN_SPINNER_ID) + assert spin["type"] == "animation" and spin["stock_path"] == "shared/spinner_front_8x8.anim" + assert spin["x"] == 64 and spin["y"] == 0 + title = next(e for e in els if e["id"] == "title") + assert title["width"] == 60 # reserved so the scrolling title never runs under the spinner + +def test_no_spinner_and_full_title_when_off(): + p = build_overlay_payload(OVERLAY_FRAME_CI_BADGE, 10, running=_running(), show_spinner=False) + els = p["elements"] + assert not any(e["id"] == RUN_SPINNER_ID for e in els) + assert next(e for e in els if e["id"] == "title")["width"] == 68 # unchanged From aa0f144bfd8a61ada0e30d8b686f3263740d2e65 Mon Sep 17 00:00:00 2001 From: John Osumi <931193+sumitake@users.noreply.github.com> Date: Thu, 6 Aug 2026 23:51:58 -0700 Subject: [PATCH 7/9] docs: document animation accents + config for calendar and ci --- config.example.toml | 4 +++ integrations/calendar_countdown/README.md | 32 +++++++++++++++++++++++ integrations/ci_status/README.md | 9 +++++++ 3 files changed, 45 insertions(+) diff --git a/config.example.toml b/config.example.toml index 6bb5e19..389bc2b 100644 --- a/config.example.toml +++ b/config.example.toml @@ -47,6 +47,9 @@ approach_minutes = 30 # inside this (outside notice_minutes): priority ri # overlay tier (PRIORITY_AMBIENT_RAISED) -- normal palette, unchanged imminent_minutes = 1 # inside this (and not yet started): LED blinks on every draw chirp = true # one-time audio chirp exactly at event start (T-0); false disables audio +escalation_icons = true # animated calendar icons at the 5-min / 1-min stages +start_animation = "meeting_72x16" # full-panel takeover for the first minute after an event starts; "" disables +start_window_seconds = 60 # how long that takeover holds [ci_status] poll_seconds = 120 @@ -57,6 +60,7 @@ show_running = true # show a badge (alternating with the calendar) whil running_poll_seconds = 20 # poll interval while a run is active (shortened from poll_seconds) show_quota = true # GraphQL/REST quota frames join the overlay rotation while a run # is active (no effect if show_running is false) +running_spinner = true # animated 8x8 spinner on the running badge # Account-wide watching (v1.5.1) -- off by default. When on, the watch list # becomes auto-discovered account repos UNION `repos` above, MINUS diff --git a/integrations/calendar_countdown/README.md b/integrations/calendar_countdown/README.md index fb230ec..cb1c003 100644 --- a/integrations/calendar_countdown/README.md +++ b/integrations/calendar_countdown/README.md @@ -94,6 +94,9 @@ Verify that the output shows your next upcoming event with the correct countdown | `approach_minutes` | integer | 30 | v1.5.2 escalation ladder: inside this window (and outside `notice_minutes`) the draw priority rises above the overlay tier. See "Escalation ladder" below. | | `imminent_minutes` | integer | 1 | Inside this window (event not yet started), the LED blinks on every draw. | | `chirp` | boolean | true | Play a one-time audio chirp exactly at event start (T-0). Set false to disable audio entirely. | +| `escalation_icons` | boolean | true | Show animated calendar icons at the warn (5-min, `calendar_event_16x16`) and imminent (1-min, `calendar_reminder_16x16`) stages. Set false for text-only display. | +| `start_animation` | string | `"meeting_72x16"` | Full-panel stock animation for the first `start_window_seconds` after an event begins. Set to `""` to disable. The animation's baked word (e.g., "MEETING") displays for every event; no per-event-type mapping. | +| `start_window_seconds` | integer | 60 | Duration the start takeover animation holds after an event begins (and at urgent priority, aligned with the T-0 chirp). | ## Autostart @@ -184,3 +187,32 @@ Independent of the priority ladder above, two more signals fire during the final **Verifying the LED assumption.** To confirm the LED-during-a-session assumption on real hardware: with `notice_minutes`/`warn_minutes`/`imminent_minutes` set low enough to reach the imminent window quickly (or just wait for a real event to approach), start a BUSY/CUSTOM session on the device (the physical start button) while an event is inside its `imminent_minutes` window, and watch the LED. If it keeps blinking through the session, the assumption holds and no further action is needed. If it goes dark once the session starts, the assumption in `busybar/display.py`'s `PRIORITY_AMBIENT_URGENT` docstring is wrong and should be corrected (and the LED can no longer be relied on as a session-safe signal for this or any future integration). **Once-per-event semantics.** The chirp fires on the transition edge only -- the poll where this *process* observes an event go from upcoming to started -- tracked in memory, keyed by `(start timestamp, title)`, not the start timestamp alone (two distinct events that happen to share the exact same start -- two all-day events both effectively at midnight, or two calendars firing something simultaneously -- are tracked independently, so chirping one never silently marks the other as already handled). It will not repeat on subsequent polls while the same event stays in progress. **Restart edge case**: this tracking is in-memory only, so a process restart during an event's final minute (or any time after it has already started) does not re-fire the chirp for that event -- the new process never observed it as "upcoming," so the edge is never detected. This is a deliberate tradeoff (documented, not a bug): the alternative (chirping on level-detection alone) would risk a spurious chirp on every restart during an active event. + +## Animation Accents + +This integration uses device stock animations to accent the calendar escalation and event-start moments. + +### Pre-Start Icons (Warn and Imminent Stages) + +When `escalation_icons` is true (default), animated calendar icons accompany the text-based countdown: + +- **Warn stage** (within `warn_minutes` of start, e.g., 5 minutes): a `calendar_event_16x16` icon animates at the top-left corner. The event title shifts rightward to `x=18` and scrolls within the narrowed space; the large countdown numeral remains at its usual position. Palette is red, priority is `PRIORITY_AMBIENT_URGENT` (65). +- **Imminent stage** (within `imminent_minutes` of start, e.g., 1 minute): the animated icon switches to `calendar_reminder_16x16` (calendar + bell). The event title is dropped to give the icon and countdown full prominence. Palette remains red, priority unchanged (65). + +Setting `escalation_icons = false` reverts both stages to text-only display (countdown numeral and title only, no icons). + +### Start Takeover (First Minute After Event Begins) + +When an event begins and `start_animation` is configured (default `"meeting_72x16"`), the display transitions to a full-panel animated takeover for the first `start_window_seconds` (default 60 seconds): + +- **Display**: the entire panel shows the configured stock animation looping continuously (e.g., `meeting_72x16` displays an animated word "MEETING" and occupies ~97% of the 72×16 panel). +- **Priority**: held at `PRIORITY_AMBIENT_URGENT` (65) for the entire window — matching the T-0 chirp's urgency, ensuring the alarm is not silently buried by other displays. +- **Timing**: aligned with the audio chirp at T-0 (event start). The animation self-loops between the integration's own redraws (every `poll_seconds`), so motion is smooth across the window. +- **After the window**: reverts to the normal in-progress "ENDS" display at `PRIORITY_AMBIENT` (20). +- **Disabled**: set `start_animation = ""` to skip the takeover entirely — in-progress will behave as before, showing the "ENDS" label from T-0 without an animated accent. + +**Note on the baked word.** Stock animations carry a fixed visual word (e.g., "MEETING" in the default `meeting_72x16`). This animation displays the same word for every event start, regardless of event title, calendar, or type. No per-event-type mapping or dynamic text insertion is performed — it is a configurable static announcement. + +### Stock Animation Paths + +All animations are device stock assets referenced by `stock_path` (e.g., `"shared/calendar_event_16x16.anim"`, `"shared/calendar_reminder_16x16.anim"`, `"shared/meeting_72x16.anim"`). No custom animation assets are uploaded or bundled with this integration; the device firmware ships all referenced stock animations at `/ext/apps_assets/shared/animations/`. If you configure a `start_animation` value that is not a device stock animation, the draw will fail gracefully (logged, not a crash) and the event display falls back to the normal text-only in-progress mode. diff --git a/integrations/ci_status/README.md b/integrations/ci_status/README.md index 0d2552b..548e93a 100644 --- a/integrations/ci_status/README.md +++ b/integrations/ci_status/README.md @@ -83,6 +83,7 @@ Once the foreground test completes, your `config.toml` is in place and GitHub au | `show_running` | boolean | true | Show the running-CI badge while a run is `in_progress` (across all configured repos; most-recently-started wins, `+N` if others are also running) | | `running_poll_seconds` | integer | 20 | Poll interval while a run is active (shortened from `poll_seconds`) | | `show_quota` | boolean | true | Join two GitHub API quota frames (GraphQL, REST) to the overlay rotation while a run is active. No effect if `show_running` is false — the quota frames only ever appear as part of that same rotation. | +| `running_spinner` | boolean | true | Show an animated 8×8 spinner in the top-right corner of the running badge. Reduces the title's available width to prevent overlap. Set false for text-only running badge. No effect if `show_running` is false. | | `watch_account_repos` | boolean | false | Auto-discover and watch every repo you own, in addition to `repos`. See "Account-wide watching" below. | | `repos_exclude` | array of strings | `[]` | Repos to never watch, regardless of mode — silences a specific repo without leaving account mode (or, less commonly, without editing `repos`). Applied last, unconditionally; a no-op when empty. | | `active_within_days` | integer | 30 | In account mode, only auto-discovered repos pushed within this many days are watched (caps request volume on large accounts). Repos in `repos` are never subject to this filter. | @@ -171,6 +172,14 @@ per dwell slot (`OVERLAY_DWELL_SECONDS`, 10s), before repeating: remaining-time label would be wrong, not just superfluous). Whether it fits at all, and which word if so, is a width-based decision (see `ci_status/logic.py`'s `_eta_label`); nothing to configure. + + **When `running_spinner` is true (default)**, an animated 8×8 spinner is + displayed in the top-right corner of the badge, indicating work in + progress. The spinner uses a device stock animation (`spinner_front_8x8`) + and animates continuously throughout the run. The title's available width + is automatically reduced from 68 to 60 pixels to prevent overlap with the + spinner. Set `running_spinner = false` to disable the spinner and revert + to text-only display (title width restored to full 68 pixels). 2. **GraphQL quota** (`show_quota`): title ribbon `GITHUB GRAPHQL`, a track bar showing the fraction of the bucket used, and two numerals — percentage *remaining* on the left, reset-in on the right (e.g. `18%` / `42m`). From f691ea5b45012bd246ae7e0b44f66f6ab5ad9bd6 Mon Sep 17 00:00:00 2001 From: John Osumi <931193+sumitake@users.noreply.github.com> Date: Fri, 7 Aug 2026 00:05:06 -0700 Subject: [PATCH 8/9] docs: fix factual errors and add missing design spec references --- integrations/calendar_countdown/README.md | 12 +++++++----- integrations/ci_status/README.md | 2 +- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/integrations/calendar_countdown/README.md b/integrations/calendar_countdown/README.md index cb1c003..f64a22c 100644 --- a/integrations/calendar_countdown/README.md +++ b/integrations/calendar_countdown/README.md @@ -190,22 +190,22 @@ Independent of the priority ladder above, two more signals fire during the final ## Animation Accents -This integration uses device stock animations to accent the calendar escalation and event-start moments. +This integration uses device stock animations to accent the calendar escalation and event-start moments. See the design spec (`docs/superpowers/specs/2026-08-06-animation-accents-design.md`) for the technical background and verification results. ### Pre-Start Icons (Warn and Imminent Stages) When `escalation_icons` is true (default), animated calendar icons accompany the text-based countdown: -- **Warn stage** (within `warn_minutes` of start, e.g., 5 minutes): a `calendar_event_16x16` icon animates at the top-left corner. The event title shifts rightward to `x=18` and scrolls within the narrowed space; the large countdown numeral remains at its usual position. Palette is red, priority is `PRIORITY_AMBIENT_URGENT` (65). +- **Warn stage** (within `warn_minutes` of start, e.g., 5 minutes): a `calendar_event_16x16` icon animates at the top-left corner. The event title is shown to the right of the icon, in the reduced space between the icon and the countdown numeral; the large countdown numeral remains at its usual position. The start-time text (`HH:MM`) is dropped to make room. Palette is red, priority is `PRIORITY_AMBIENT_URGENT` (65). - **Imminent stage** (within `imminent_minutes` of start, e.g., 1 minute): the animated icon switches to `calendar_reminder_16x16` (calendar + bell). The event title is dropped to give the icon and countdown full prominence. Palette remains red, priority unchanged (65). -Setting `escalation_icons = false` reverts both stages to text-only display (countdown numeral and title only, no icons). +Setting `escalation_icons = false` reverts both stages to text-only display: title, start-time (`HH:MM`), and countdown numeral, with no icons. ### Start Takeover (First Minute After Event Begins) When an event begins and `start_animation` is configured (default `"meeting_72x16"`), the display transitions to a full-panel animated takeover for the first `start_window_seconds` (default 60 seconds): -- **Display**: the entire panel shows the configured stock animation looping continuously (e.g., `meeting_72x16` displays an animated word "MEETING" and occupies ~97% of the 72×16 panel). +- **Display**: the entire panel shows the configured stock animation looping continuously (e.g., `meeting_72x16` displays an animated word "MEETING" and occupies ~98% of the 72×16 panel). - **Priority**: held at `PRIORITY_AMBIENT_URGENT` (65) for the entire window — matching the T-0 chirp's urgency, ensuring the alarm is not silently buried by other displays. - **Timing**: aligned with the audio chirp at T-0 (event start). The animation self-loops between the integration's own redraws (every `poll_seconds`), so motion is smooth across the window. - **After the window**: reverts to the normal in-progress "ENDS" display at `PRIORITY_AMBIENT` (20). @@ -215,4 +215,6 @@ When an event begins and `start_animation` is configured (default `"meeting_72x1 ### Stock Animation Paths -All animations are device stock assets referenced by `stock_path` (e.g., `"shared/calendar_event_16x16.anim"`, `"shared/calendar_reminder_16x16.anim"`, `"shared/meeting_72x16.anim"`). No custom animation assets are uploaded or bundled with this integration; the device firmware ships all referenced stock animations at `/ext/apps_assets/shared/animations/`. If you configure a `start_animation` value that is not a device stock animation, the draw will fail gracefully (logged, not a crash) and the event display falls back to the normal text-only in-progress mode. +All animations are device stock assets referenced by `stock_path` (e.g., `"shared/calendar_event_16x16.anim"`, `"shared/calendar_reminder_16x16.anim"`, `"shared/meeting_72x16.anim"`). No custom animation assets are uploaded or bundled with this integration; the device firmware ships all referenced stock animations at `/ext/apps_assets/shared/animations/`. The `start_animation` value must be a valid device stock animation name — examples include `meeting_72x16`, `booked_72x16`, `wave_invitation_72x16`. Setting `start_animation = ""` disables the takeover. An invalid or misspelled animation name will not render, leaving the panel dark for the takeover window. + +See the design spec (`docs/superpowers/specs/2026-08-06-animation-accents-design.md`, § 3) for the full list of verified stock animations and their dimensions. diff --git a/integrations/ci_status/README.md b/integrations/ci_status/README.md index 548e93a..0670fa5 100644 --- a/integrations/ci_status/README.md +++ b/integrations/ci_status/README.md @@ -154,7 +154,7 @@ priority table. While any configured repo has an `in_progress` run (and nothing is failing or stuck), the device rotates through up to three overlay-tier frames, one -per dwell slot (`OVERLAY_DWELL_SECONDS`, 10s), before repeating: +per dwell slot (`OVERLAY_DWELL_SECONDS`, 10s), before repeating. See the design spec (`docs/superpowers/specs/2026-08-06-animation-accents-design.md`) for the running spinner implementation details. 1. **Running badge** (always first, always present when `show_running` is on): `REPO #PR WORKFLOW` (or `REPO branch-name WORKFLOW` for From 3b2b8c1015aedab93a1061807bda654e78c36faf Mon Sep 17 00:00:00 2001 From: John Osumi <931193+sumitake@users.noreply.github.com> Date: Fri, 7 Aug 2026 00:20:32 -0700 Subject: [PATCH 9/9] calendar: fix warn-title scroll width + drop orphaned divider; hermetic config test --- integrations/calendar_countdown/logic.py | 53 +++++++++++++++++------- tests/test_calendar_logic.py | 32 ++++++++++++++ tests/test_config.py | 4 +- 3 files changed, 73 insertions(+), 16 deletions(-) diff --git a/integrations/calendar_countdown/logic.py b/integrations/calendar_countdown/logic.py index 7221aee..5488ef6 100644 --- a/integrations/calendar_countdown/logic.py +++ b/integrations/calendar_countdown/logic.py @@ -729,8 +729,26 @@ def build_elements(event: CalEvent, now: datetime, cfg: dict, timeout_s: int, if imminent: title_element = None # drop the title at imminent -> icon + big number else: - title_element.update({"x": ICON_TITLE_X, - "width": CD_TEXT_X - ICON_TITLE_X - 2}) # scroll in the gap + narrowed_width = CD_TEXT_X - ICON_TITLE_X - 2 # scroll in the gap + title_element.update({"x": ICON_TITLE_X, "width": narrowed_width}) + # The scroll decision above was made against the full + # TITLE_WIDTH (68px); the icon block just narrowed the title to + # `narrowed_width` (19px), so it must be RE-decided against the + # narrowed width here -- a title that fits at 68px commonly does + # NOT fit at 19px (e.g. "Standup", "Meeting", "Lunch"), and + # without this recompute it would keep the no-scroll flags from + # the 68px check and clip statically instead of scrolling in the + # gap the comment above promises. + if not _title_fits(title, narrowed_width): + title_element.update({ + "scroll_rate": SCROLL_RATE, + "scroll_start_delay": SCROLL_DELAY_MS, + "scroll_repeat_delay": SCROLL_DELAY_MS, + }) + else: + title_element.pop("scroll_rate", None) + title_element.pop("scroll_start_delay", None) + title_element.pop("scroll_repeat_delay", None) track_element = { "id": "track", @@ -800,18 +818,25 @@ def build_elements(event: CalEvent, now: datetime, cfg: dict, timeout_s: int, "timeout": timeout_s, }) - elements.append({ - "id": "divider", - "type": "rectangle", - "x": DIVIDER_X, - "y": DIVIDER_Y, - "width": DIVIDER_WIDTH, - "height": DIVIDER_HEIGHT, - "fill": "solid", - "fill_colors": [DIVIDER_COLOR[state]], - "border_width": 0, - "timeout": timeout_s, - }) + if icon_element is None: + # The divider's left neighbor is `time`, which is already dropped + # whenever the escalation icon is present (both the warn and + # imminent sub-stages, see the `elif icon_element is None` branch + # above) -- with `time` gone and `title` narrowed away from it too, + # the divider would be orphaned floating alone. Drop it under the + # same condition rather than let it draw disconnected from anything. + elements.append({ + "id": "divider", + "type": "rectangle", + "x": DIVIDER_X, + "y": DIVIDER_Y, + "width": DIVIDER_WIDTH, + "height": DIVIDER_HEIGHT, + "fill": "solid", + "fill_colors": [DIVIDER_COLOR[state]], + "border_width": 0, + "timeout": timeout_s, + }) elements.append({ "id": "cd_text", diff --git a/tests/test_calendar_logic.py b/tests/test_calendar_logic.py index b49fbdc..9a8842d 100644 --- a/tests/test_calendar_logic.py +++ b/tests/test_calendar_logic.py @@ -16,6 +16,7 @@ next_sleep_seconds, IMMINENT_LED_COLOR, CHIRP_STOCK_PATH, _chirp_key, check_threshold_ordering, is_just_started, START_ANIM_ID, CAL_ICON_ID, ICON_EVENT, ICON_REMINDER, + SCROLL_RATE, SCROLL_DELAY_MS, ) from busybar.display import ( PRIORITY_AMBIENT, PRIORITY_AMBIENT_RAISED, PRIORITY_AMBIENT_URGENT, @@ -742,12 +743,43 @@ def test_icon_present_drops_time_element(): assert not any(e["id"] == "time" for e in els) assert any(e["id"] == CAL_ICON_ID for e in els) assert any(e["id"] == "cd_text" for e in els) + # `divider` (id "divider", at DIVIDER_X) sits to the right of `time`'s + # old position -- with `time` dropped and `title` narrowed, the divider + # is orphaned and must be dropped too whenever the icon shows (CODE-BUG, + # minor fix wave item 2). + assert not any(e["id"] == "divider" for e in els) imminent_ev = _ev(NOW2 + timedelta(seconds=30)) # 0.5m out -> imminent els = build_elements(imminent_ev, NOW2, _cfg(), 15, in_progress=False) assert not any(e["id"] == "time" for e in els) assert any(e["id"] == CAL_ICON_ID for e in els) assert any(e["id"] == "cd_text" for e in els) + assert not any(e["id"] == "divider" for e in els) + + # Normal (no-icon) upcoming display is unaffected -- the divider must + # still be present when there's no icon to orphan it. + no_icon_els = build_elements(_ev(NOW2 + timedelta(minutes=4)), NOW2, + _cfg(escalation_icons=False), 15, in_progress=False) + assert any(e["id"] == "divider" for e in no_icon_els) + + +def test_warn_stage_title_scrolls_in_narrowed_icon_gap(): + # "STANDUP" (7 chars * SMALL_FONT_CHAR_PX=5 -> 35px) fits the full 68px + # TITLE_WIDTH the scroll decision is first made against, but does NOT + # fit the icon-narrowed gap the escalation-icon block later shrinks the + # title into (CD_TEXT_X - ICON_TITLE_X - 2 = 39 - 18 - 2 = 19px). The + # scroll decision must be recomputed against the NARROWED width, or a + # title that's the common case (most real meeting titles) gets no + # scroll flags and is statically clipped in the 19px band -- contradicting + # the code's own "# scroll in the gap" comment (CODE-BUG-A). + warn_ev = _ev(NOW2 + timedelta(minutes=4)) # 4m out -> warn, > imminent + els = build_elements(warn_ev, NOW2, _cfg(), 15, in_progress=False) + title_el = next(e for e in els if e["id"] == "title") + assert title_el["text"] == "STANDUP" + assert "scroll_rate" in title_el + assert title_el["scroll_rate"] == SCROLL_RATE + assert title_el["scroll_start_delay"] == SCROLL_DELAY_MS + assert title_el["scroll_repeat_delay"] == SCROLL_DELAY_MS def test_imminent_stage_uses_reminder_icon_and_drops_title(): ev = _ev(NOW2 + timedelta(seconds=30)) # 0.5m out -> imminent diff --git a/tests/test_config.py b/tests/test_config.py index c0fab93..1b319a2 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -113,8 +113,8 @@ def test_device_kwargs_no_warnings_when_all_keys_known(caplog): device_kwargs({"device": dict(DEFAULTS["device"])}) assert not [r for r in caplog.records if r.levelname == "WARNING"] -def test_animation_accent_defaults(): - cfg = load_config(path=None) +def test_animation_accent_defaults(tmp_path): + cfg = load_config(tmp_path / "missing.toml") cal = cfg["calendar_countdown"] assert cal["escalation_icons"] is True assert cal["start_animation"] == "meeting_72x16"