diff --git a/README.md b/README.md index 2efb7de..d836857 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,15 @@ Local-API integrations for the BUSY Bar — a 72×16 LED status display on USB or LAN. +## Display design + +The **Signal** display style pairs dark backgrounds with bright time readouts, +consistent status cards, and native animation. Urgent calendar screens retain +the event title; CI status headings stay fixed while details scroll. Nyan has +a softer rainbow trail and a seamless star loop. See the +[design and device captures](docs/display-design.md) for the before/after +comparison and unchanged priority/handoff behavior. + ## Requirements - **BUSY Bar** on USB (default address `10.0.4.20`) or LAN diff --git a/assets/nyan/frames/frame_0.png b/assets/nyan/frames/frame_0.png index 4878723..db6750a 100644 Binary files a/assets/nyan/frames/frame_0.png and b/assets/nyan/frames/frame_0.png differ diff --git a/assets/nyan/frames/frame_1.png b/assets/nyan/frames/frame_1.png index 607966f..f561fda 100644 Binary files a/assets/nyan/frames/frame_1.png and b/assets/nyan/frames/frame_1.png differ diff --git a/assets/nyan/frames/frame_10.png b/assets/nyan/frames/frame_10.png index d64f6f3..cd6d8b0 100644 Binary files a/assets/nyan/frames/frame_10.png and b/assets/nyan/frames/frame_10.png differ diff --git a/assets/nyan/frames/frame_11.png b/assets/nyan/frames/frame_11.png index dd9631f..588e834 100644 Binary files a/assets/nyan/frames/frame_11.png and b/assets/nyan/frames/frame_11.png differ diff --git a/assets/nyan/frames/frame_12.png b/assets/nyan/frames/frame_12.png index bb6e93b..811a5ff 100644 Binary files a/assets/nyan/frames/frame_12.png and b/assets/nyan/frames/frame_12.png differ diff --git a/assets/nyan/frames/frame_13.png b/assets/nyan/frames/frame_13.png index 46bac67..9b62e38 100644 Binary files a/assets/nyan/frames/frame_13.png and b/assets/nyan/frames/frame_13.png differ diff --git a/assets/nyan/frames/frame_14.png b/assets/nyan/frames/frame_14.png index 00ac6a5..d458c31 100644 Binary files a/assets/nyan/frames/frame_14.png and b/assets/nyan/frames/frame_14.png differ diff --git a/assets/nyan/frames/frame_15.png b/assets/nyan/frames/frame_15.png index 4f90279..73d0c0b 100644 Binary files a/assets/nyan/frames/frame_15.png and b/assets/nyan/frames/frame_15.png differ diff --git a/assets/nyan/frames/frame_16.png b/assets/nyan/frames/frame_16.png index 29b294f..9c6b128 100644 Binary files a/assets/nyan/frames/frame_16.png and b/assets/nyan/frames/frame_16.png differ diff --git a/assets/nyan/frames/frame_17.png b/assets/nyan/frames/frame_17.png index 2a75462..61f6518 100644 Binary files a/assets/nyan/frames/frame_17.png and b/assets/nyan/frames/frame_17.png differ diff --git a/assets/nyan/frames/frame_18.png b/assets/nyan/frames/frame_18.png index d327a58..1bd4c6b 100644 Binary files a/assets/nyan/frames/frame_18.png and b/assets/nyan/frames/frame_18.png differ diff --git a/assets/nyan/frames/frame_19.png b/assets/nyan/frames/frame_19.png index bbdf379..94a1301 100644 Binary files a/assets/nyan/frames/frame_19.png and b/assets/nyan/frames/frame_19.png differ diff --git a/assets/nyan/frames/frame_2.png b/assets/nyan/frames/frame_2.png index 7311572..36686a9 100644 Binary files a/assets/nyan/frames/frame_2.png and b/assets/nyan/frames/frame_2.png differ diff --git a/assets/nyan/frames/frame_20.png b/assets/nyan/frames/frame_20.png index 657565b..471c008 100644 Binary files a/assets/nyan/frames/frame_20.png and b/assets/nyan/frames/frame_20.png differ diff --git a/assets/nyan/frames/frame_21.png b/assets/nyan/frames/frame_21.png index a7f5cf7..2c97ee1 100644 Binary files a/assets/nyan/frames/frame_21.png and b/assets/nyan/frames/frame_21.png differ diff --git a/assets/nyan/frames/frame_22.png b/assets/nyan/frames/frame_22.png index a7f5cf7..b42ceb5 100644 Binary files a/assets/nyan/frames/frame_22.png and b/assets/nyan/frames/frame_22.png differ diff --git a/assets/nyan/frames/frame_23.png b/assets/nyan/frames/frame_23.png index a7f5cf7..85ab6dc 100644 Binary files a/assets/nyan/frames/frame_23.png and b/assets/nyan/frames/frame_23.png differ diff --git a/assets/nyan/frames/frame_3.png b/assets/nyan/frames/frame_3.png index 7135822..27d25e4 100644 Binary files a/assets/nyan/frames/frame_3.png and b/assets/nyan/frames/frame_3.png differ diff --git a/assets/nyan/frames/frame_4.png b/assets/nyan/frames/frame_4.png index 9dde8f3..6763065 100644 Binary files a/assets/nyan/frames/frame_4.png and b/assets/nyan/frames/frame_4.png differ diff --git a/assets/nyan/frames/frame_5.png b/assets/nyan/frames/frame_5.png index fb36af4..87d2307 100644 Binary files a/assets/nyan/frames/frame_5.png and b/assets/nyan/frames/frame_5.png differ diff --git a/assets/nyan/frames/frame_6.png b/assets/nyan/frames/frame_6.png index 16f2f44..261b4b2 100644 Binary files a/assets/nyan/frames/frame_6.png and b/assets/nyan/frames/frame_6.png differ diff --git a/assets/nyan/frames/frame_7.png b/assets/nyan/frames/frame_7.png index cb566eb..13cc26a 100644 Binary files a/assets/nyan/frames/frame_7.png and b/assets/nyan/frames/frame_7.png differ diff --git a/assets/nyan/frames/frame_8.png b/assets/nyan/frames/frame_8.png index 5b51755..b99c6d7 100644 Binary files a/assets/nyan/frames/frame_8.png and b/assets/nyan/frames/frame_8.png differ diff --git a/assets/nyan/frames/frame_9.png b/assets/nyan/frames/frame_9.png index ad7cba8..d53fb26 100644 Binary files a/assets/nyan/frames/frame_9.png and b/assets/nyan/frames/frame_9.png differ diff --git a/assets/nyan/nyan_72x16.anim b/assets/nyan/nyan_72x16.anim index 84c6a8f..7d6bc4d 100644 Binary files a/assets/nyan/nyan_72x16.anim and b/assets/nyan/nyan_72x16.anim differ diff --git a/docs/display-design.md b/docs/display-design.md new file mode 100644 index 0000000..3d2c4cf --- /dev/null +++ b/docs/display-design.md @@ -0,0 +1,84 @@ +# Signal display design + +Signal gives the calendar, CI status, and idle animation a shared visual style: +dark navy backgrounds, bright numerals, thin progress tracks, and native motion. +Cyan signals activity, amber approaching or waiting, coral urgency or failure, +and mint an active event or healthy CI. Text labels keep each state identifiable +without relying on color alone. + +![Signal screens captured from a BUSY Bar](images/signal-overview.png) + +These are native **72×16 framebuffer captures**, enlarged with nearest-neighbor +scaling. They use fixed demonstration events and runs, not live calendar content. +The screenshots show the start of scrolling titles; the full text remains in +the device payload. LED brightness and diffusion can look different in person. + +## Calendar flow + +The familiar sequence stays intact: upcoming → approaching → warning → imminent +→ start animation → in progress. The redesign makes that sequence easier to read: + +- The large start time and countdown retain their existing size and positions. +- Warning and imminent screens reserve a 16-pixel lane for the animated calendar + icon and a **52-pixel title lane**. The title remains visible through the final + minute, with `IN <1m` below it. +- A bright endpoint makes the progress track easier to locate. Upcoming tracks + drain toward the start; active-event tracks now drain against the event's + duration, alongside the existing `ENDS` countdown. +- The existing full-screen start animation still provides the transition into + an active event. + +![Calendar before and after](images/signal-calendar.png) + +## CI flow + +Failure, waiting, and healthy cards share a fixed heading and a bold lower row. +`CI FAIL`, `CI WAIT`, and `CI OK` stay visible while repository, ref, and workflow +details scroll. The healthy state uses the compact, static `ALL CLEAR` label. +Firmware 1.2.3 adds a small status icon beside the heading; the words remain +readable when icons are disabled or unavailable over an older/cloud route. + +Running builds retain their large ETA. Moving the native spinner to the +lower-right corner gives the title its full width, while a separate text budget +keeps ETA labels clear of the spinner. The progress track uses a cyan-to-mint +gradient with a bright endpoint. + +Quota screens label the left value `GQL LEFT` or `REST LEFT`, and the right +value `RESET`, with a thin separator. Their percentages and reset countdowns +keep the existing meaning. + +![CI before and after](images/signal-ci.png) + +## Idle animation + +Nyan keeps its recognizable silhouette and 12 fps motion, with a navy backdrop, +a rainbow that fades toward the tail, and a star pattern that repeats seamlessly +over the 24-frame loop. The device plays the uploaded animation as before. + +![Nyan source-frame animation preview](images/signal-nyan.gif) + +The GIF is an enlarged preview generated from the committed source frames, +not a recording of the physical display. The encoded `.anim` was also uploaded +under a separate preview application and successfully drawn by the device. + +## Behavior and validation + +This is a presentation change. Event selection, escalation thresholds, +integration priorities, CI rotation and dwell times, poll intervals, frame +timeouts, quiet hours, and manual-session precedence retain their existing +contracts. New shapes use the same cleanup and expiring-frame paths. Motion +runs on the device; no host animation loop, service, dependency, or new +configuration is introduced. + +Validation on firmware **1.2.3 / API 27.5.0** included native captures of five +calendar states, running/failure/waiting/healthy CI, both quota labels, and an +encoded Nyan asset draw. The final-minute `<` glyph, `ALL CLEAR`, and `REST LEFT` +were checked on the native framebuffer. Temporary preview frames used their +own application name, priority below manual sessions, a five-second timeout, +and application-scoped cleanup. Production jobs were not restarted for these +previews. + +The automated suite passes **445 tests**, covering existing selection and +handoff behavior plus title retention, track bounds, spinner separation, CI log +context, and deterministic animation generation. All 24 PNG frames and the +encoded animation were independently regenerated and matched byte for byte. diff --git a/docs/images/signal-calendar.png b/docs/images/signal-calendar.png new file mode 100644 index 0000000..9d9aa23 Binary files /dev/null and b/docs/images/signal-calendar.png differ diff --git a/docs/images/signal-ci.png b/docs/images/signal-ci.png new file mode 100644 index 0000000..1b5e7ec Binary files /dev/null and b/docs/images/signal-ci.png differ diff --git a/docs/images/signal-nyan.gif b/docs/images/signal-nyan.gif new file mode 100644 index 0000000..819aa8d Binary files /dev/null and b/docs/images/signal-nyan.gif differ diff --git a/docs/images/signal-overview.png b/docs/images/signal-overview.png new file mode 100644 index 0000000..4144050 Binary files /dev/null and b/docs/images/signal-overview.png differ diff --git a/integrations/calendar_countdown/README.md b/integrations/calendar_countdown/README.md index 635a07f..5b04548 100644 --- a/integrations/calendar_countdown/README.md +++ b/integrations/calendar_countdown/README.md @@ -4,12 +4,12 @@ This integration polls your macOS calendar for upcoming events and displays a live countdown on the busybar device: a full-panel gradient background that itself signals urgency, an uppercase title row, a horizontal drain track, and large numerals (start time, or countdown) floating directly on the background — no card surfaces, for an airy, high-contrast look. -- **Color Horizon background** — the whole panel is a top-to-bottom gradient whose colors shift with urgency: deep violet when the event is far off, warm orange approaching `notice_minutes`, red approaching `warn_minutes`, and teal while the event is in progress. Urgency reads at a glance before you even look at the countdown. +- **Signal background** — the whole panel is a top-to-bottom gradient whose colors shift with urgency: deep navy when the event is far off, warm orange approaching `notice_minutes`, red approaching `warn_minutes`, and teal while the event is in progress. Urgency reads at a glance before you even look at the countdown. - **Title row** — the event title, uppercased, in a small font flush across the top. Long titles scroll; short ones sit static. -- **Drain track** — a thin horizontal line spanning the full width, edge to edge, below the title. It fills proportionally to time remaining within `progress_window_minutes` (default 60) and shrinks toward empty as the start time arrives. An in-progress event shows it full-width and steady instead (no drain — the relevant countdown is now "time until it ends"). +- **Drain track** — a thin horizontal line spanning the full width, edge to edge, below the title. It fills proportionally to time remaining within `progress_window_minutes` (default 60) and shrinks toward empty as the start time arrives. During an active event, the track drains against the event duration. A bright endpoint makes the remaining fraction easy to locate. - **Time / Ends** — a large local `HH:MM` start time for an upcoming event, or a bold "ENDS" label once the event has begun. No card behind either — both float directly on the gradient background. - **Countdown** — a large minutes-granular countdown, same size as the time/ends numeral (they always change together): `"54m"` under an hour, `"1h05m"` at/above an hour, falling back to hour-only (`"9h"`) whenever the combined form would run too wide for the space available — which font-width measurement shows can happen even for some single-digit-hour values, not just at 10+ hours. Counts to the event start while upcoming, or to its end once in progress; re-rendered each poll rather than ticking natively on-device, so it updates on the same cadence as the rest of the display (`poll_seconds`). -- **Four states** — `normal`, `notice` (within `notice_minutes` of start), `warning` (within `warn_minutes` of start), and `in_progress`, each with its own background gradient, title color, drain-track gradient, divider color, and digit color. See the design spec (`docs/superpowers/specs/2026-08-03-calendar-ci-integrations-design.md`) for the full palette table and row-budget diagram. +- **Four states** — `normal`, `notice` (within `notice_minutes` of start), `warning` (within `warn_minutes` of start), and `in_progress`, each with its own background gradient, title color, drain-track gradient, divider color, and digit color. See the [current design and device captures](../../docs/display-design.md) for the visual flow. The integration looks ahead 12 hours by default and draws at the ambient tier (`busybar.display.PRIORITY_AMBIENT`, priority 20) on the display. If an active BUSY session exists on the device, the calendar event display is suppressed in favor of the busy state (priority 90). If the `ci_status` integration is also running with `show_running` and/or `show_quota` enabled, the calendar and the overlay rotation (running badge, GraphQL/REST quota frames) trade the screen back and forth for as long as a CI run is active — see `ci_status`'s README ("Display Priority Tiers" / alternation rhythm) for the measured numbers; the panel still goes fully dark for a few seconds in most cycles because the firmware never restores an occluded element on its own (see "Display Priority Tiers" below), but at the tuned 10s ambient poll the calendar now recovers the screen in roughly 3 of every 4 overlay dwell gaps rather than effectively never. @@ -196,8 +196,8 @@ This integration uses device stock animations to accent the calendar escalation 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 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). +- **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, across a reserved 52-pixel title lane above the countdown; 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 title stays visible; the lower row reads `IN <1m` during the final positive fraction of a minute. Palette remains red, priority unchanged (65). Setting `escalation_icons = false` reverts both stages to text-only display: title, start-time (`HH:MM`), and countdown numeral, with no icons. diff --git a/integrations/calendar_countdown/logic.py b/integrations/calendar_countdown/logic.py index 5488ef6..247caa2 100644 --- a/integrations/calendar_countdown/logic.py +++ b/integrations/calendar_countdown/logic.py @@ -8,7 +8,7 @@ PANEL_WIDTH = 72 PANEL_HEIGHT = 16 -# --- v1.3 "Color Horizon" state palette -------------------------------------- +# --- "Signal" state palette -------------------------------------- # # Four states, ordered least -> most urgent, with `in_progress` orthogonal to # the other three (an event currently happening, regardless of how it got @@ -22,27 +22,27 @@ STATES = (STATE_NORMAL, STATE_NOTICE, STATE_WARNING, STATE_IN_PROGRESS) BG_GRADIENT = { - STATE_NORMAL: ["#160A2EFF", "#03040DFF"], - STATE_NOTICE: ["#291300FF", "#070301FF"], - STATE_WARNING: ["#30040BFF", "#080103FF"], - STATE_IN_PROGRESS: ["#032B2CFF", "#010809FF"], + STATE_NORMAL: ["#061326FF", "#01030AFF"], + STATE_NOTICE: ["#241704FF", "#060401FF"], + STATE_WARNING: ["#2B0818FF", "#060208FF"], + STATE_IN_PROGRESS: ["#04231CFF", "#010604FF"], } TITLE_COLOR = { - STATE_NORMAL: "#FFD166FF", - STATE_NOTICE: "#FFE3A3FF", - STATE_WARNING: "#FFE7ECFF", + STATE_NORMAL: "#F2F7FFFF", + STATE_NOTICE: "#FFF2CEFF", + STATE_WARNING: "#FFF0F3FF", STATE_IN_PROGRESS: "#83FFF3FF", } # The drain track's groove is a single fixed color in every state; only the # fill on top of it (TRACK_FILL_GRADIENT / TRACK_FILL_IN_PROGRESS) changes. -TRACK_COLOR = "#24193BFF" +TRACK_COLOR = "#142033FF" TRACK_FILL_GRADIENT = { - STATE_NORMAL: ["#1ED6FFFF", "#5CFFB1FF"], + STATE_NORMAL: ["#1597FFFF", "#63FFD4FF"], STATE_NOTICE: ["#FF9F1CFF", "#FFE66DFF"], - STATE_WARNING: ["#FF204EFF", "#FF7A22FF"], + STATE_WARNING: ["#FF416EFF", "#FFC070FF"], } TRACK_FILL_IN_PROGRESS = "#24D6C5FF" @@ -54,23 +54,23 @@ # failure badge does). The v1.3.1 cards (#062238/#062A22, etc.) were # mid-luminance dim fills that read as glow-mud on emissive LEDs, so v1.4 # removes cards entirely rather than trying to re-tune their luminance. -TIME_TEXT_COLOR = "#8DDEFFFF" +TIME_TEXT_COLOR = "#B4CAE6FF" # Fixed -- only drawn for the in-progress state (replaces time + ends). ENDS_TEXT_COLOR = "#8CFFF4FF" ENDS_TEXT = "ENDS" DIVIDER_COLOR = { - STATE_NORMAL: "#643B8FFF", + STATE_NORMAL: "#284469FF", STATE_NOTICE: "#C37A0CFF", STATE_WARNING: "#E02A4CFF", STATE_IN_PROGRESS: "#178C88FF", } DIGIT_COLOR = { - STATE_NORMAL: "#6BFFD0FF", + STATE_NORMAL: "#5AF0FFFF", STATE_NOTICE: "#FFC247FF", - STATE_WARNING: "#FF4B68FF", + STATE_WARNING: "#FF6987FF", STATE_IN_PROGRESS: "#64FFEAFF", } @@ -177,14 +177,8 @@ def _text_width_px(s: str) -> int: # --- 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). +# The 16x16 warning animation owns x=0..15. The title and track use +# x=18..71 throughout warning/imminent; the lower row keeps IN + countdown. ICON_EVENT = "calendar_event_16x16" ICON_REMINDER = "calendar_reminder_16x16" ICON_X, ICON_Y = 0, 0 @@ -635,221 +629,79 @@ def _format_countdown(minutes_left: float) -> str: def build_elements(event: CalEvent, now: datetime, cfg: dict, timeout_s: int, 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, and, for the v1.6 - 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 - card/panel surfaces -- `time` and `cd_text` float directly on `bg` (see - the TIME_TEXT_COLOR comment above for why cards were removed in v1.4). - The countdown itself (`cd_text`) is a plain `text` element re-rendered - 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. - title = ascii_safe(event.title).upper() + """Compose a title, horizon and equal-size time readouts on the native canvas. + The urgent 16px animation has a reserved lane. Its title stays visible in + both warning stages, with a compact IN label beside the large countdown. + Text y coordinates include the firmware's measured two-pixel ink offset. + Full expiring frames still use the existing priority and redraw contracts. + """ minutes_left = _minutes_left(event, now, in_progress) - state = _state_for(minutes_left, cfg["notice_minutes"], cfg["warn_minutes"], in_progress) - - bg_element = { - "id": "bg", - "type": "rectangle", - "x": 0, - "y": 0, - "width": PANEL_WIDTH, - "height": PANEL_HEIGHT, - "fill": "gradient_v", - "fill_colors": BG_GRADIENT[state], - "border_width": 0, - "timeout": timeout_s, - } - - title_element = { - "id": "title", - "type": "text", - "text": title, - "font": "small", - "color": TITLE_COLOR[state], - "x": TITLE_X, - "y": TITLE_Y, - "width": TITLE_WIDTH, - "timeout": timeout_s, - } - if not _title_fits(title, TITLE_WIDTH): - title_element.update({ - "scroll_rate": SCROLL_RATE, - "scroll_start_delay": SCROLL_DELAY_MS, - "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. 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"] - 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: - 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", - "type": "rectangle", - "x": 0, - "y": TRACK_Y, - "width": PANEL_WIDTH, - "height": TRACK_HEIGHT, - "fill": "solid", - "fill_colors": [TRACK_COLOR], - # RectangleElement defaults to a 1px white border, which at this - # height would swallow the fill entirely (found on-device in v1.1). - "border_width": 0, - "timeout": timeout_s, - } - + 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 if just_started else state], + "border_width": 0, "timeout": timeout_s} + if just_started: + return [bg, {"id": START_ANIM_ID, "type": "animation", + "stock_path": f"shared/{cfg['start_animation']}.anim", + "x": 0, "y": 0, "loop": True, "timeout": timeout_s}] + + show_icon = not in_progress and cfg.get("escalation_icons") and state == STATE_WARNING + title_x = ICON_TITLE_X if show_icon else TITLE_X + title_width = PANEL_WIDTH - title_x - 2 + title_text = ascii_safe(event.title).upper() + title = {"id": "title", "type": "text", "text": title_text, "font": "small", + "color": TITLE_COLOR[state], "x": title_x, "y": TITLE_Y, + "width": title_width, "timeout": timeout_s} + if not _title_fits(title_text, title_width): + title.update(scroll_rate=SCROLL_RATE, scroll_start_delay=SCROLL_DELAY_MS, + scroll_repeat_delay=SCROLL_DELAY_MS) + + track_x = ICON_TITLE_X if show_icon else 0 + track_width = PANEL_WIDTH - track_x if in_progress: - track_fill_width = PANEL_WIDTH - track_fill = {"fill": "solid", "fill_colors": [TRACK_FILL_IN_PROGRESS]} + duration = (event.end - event.start).total_seconds() + fraction = (event.end - now).total_seconds() / duration if duration > 0 else 1.0 + fill_width = max(1, min(track_width, round(track_width * fraction))) + fill = {"fill": "gradient_h", "fill_colors": ["#159987FF", TRACK_FILL_IN_PROGRESS]} else: - track_fill_width = _track_fill_width(minutes_left, cfg["progress_window_minutes"]) - track_fill = {"fill": "gradient_h", "fill_colors": TRACK_FILL_GRADIENT[state]} - track_fill_element = { - "id": "track_fill", - "type": "rectangle", - "x": 0, - "y": TRACK_Y, - "width": track_fill_width, - "height": TRACK_HEIGHT, - "border_width": 0, - "timeout": timeout_s, - **track_fill, - } - - elements = [bg_element] - if title_element is not None: - elements.append(title_element) - elements.append(track_element) - elements.append(track_fill_element) + fill_width = max(1, min(track_width, round( + _track_fill_width(minutes_left, cfg["progress_window_minutes"]) * track_width / PANEL_WIDTH))) + fill = {"fill": "gradient_h", "fill_colors": TRACK_FILL_GRADIENT[state]} + track = {"id": "track", "type": "rectangle", "x": track_x, "y": TRACK_Y, + "width": track_width, "height": TRACK_HEIGHT, "fill": "solid", + "fill_colors": [TRACK_COLOR], "border_width": 0, "timeout": timeout_s} + track_fill = {**track, "id": "track_fill", "width": fill_width, **fill} + tip_width = min(2, fill_width) + track_tip = {**track, "id": "track_tip", "x": track_x + fill_width - tip_width, + "width": tip_width, "fill_colors": ["#E9FFFFFF"]} + elements = [bg, title, track, track_fill, track_tip] if in_progress: - elements.append({ - "id": "ends", - "type": "text", - "text": ENDS_TEXT, - "font": "bold", - "color": ENDS_TEXT_COLOR, - "x": ENDS_X, - "y": ENDS_Y, - "timeout": timeout_s, - }) - 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", - "text": f"{event.start.astimezone():%H:%M}", - "font": "large", - "color": TIME_TEXT_COLOR, - "x": TIME_X, - "y": TIME_Y, - "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", - "type": "text", - "text": _format_countdown(minutes_left), - "font": "large", - "color": DIGIT_COLOR[state], - "x": CD_TEXT_X, - "y": CD_TEXT_Y, - "timeout": timeout_s, - }) - - if icon_element is not None: - elements.append(icon_element) - + elements.append({"id": "ends", "type": "text", "text": ENDS_TEXT, "font": "bold", + "color": ENDS_TEXT_COLOR, "x": ENDS_X, "y": ENDS_Y, + "timeout": timeout_s}) + elif show_icon: + elements.append({"id": "starts_label", "type": "text", "text": "IN", "font": "small", + "color": TITLE_COLOR[state], "x": 20, "y": 9, "timeout": timeout_s}) + else: + elements.append({"id": "time", "type": "text", "text": f"{event.start.astimezone():%H:%M}", + "font": "large", "color": TIME_TEXT_COLOR, "x": TIME_X, "y": TIME_Y, + "timeout": timeout_s}) + if not show_icon: + 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}) + + # Keep the calendar's final-minute cue separate from shared ETA formatting. + countdown = "<1m" if not in_progress and 0 < minutes_left < 1 else _format_countdown(minutes_left) + elements.append({"id": "cd_text", "type": "text", "text": countdown, "font": "large", + "color": DIGIT_COLOR[state], "x": CD_TEXT_X, "y": CD_TEXT_Y, + "width": CD_TEXT_MAX_WIDTH, "timeout": timeout_s}) + if show_icon: + icon = ICON_REMINDER if minutes_left <= cfg["imminent_minutes"] else ICON_EVENT + elements.append({"id": CAL_ICON_ID, "type": "animation", "stock_path": f"shared/{icon}.anim", + "x": ICON_X, "y": ICON_Y, "loop": True, "timeout": timeout_s}) return elements diff --git a/integrations/ci_status/README.md b/integrations/ci_status/README.md index a5e4cc8..a120a2f 100644 --- a/integrations/ci_status/README.md +++ b/integrations/ci_status/README.md @@ -149,13 +149,13 @@ running spinner implementation details. ### Failure and Stuck Frames (v1.7) -A failing run draws a full-panel red badge (rounded background + bold -white text) reading `CI FAIL owner/repo #42 · workflow`; a stale-queued -run draws the same layout in amber with black text, reading `CI stuck -owner/repo #42 · workflow`. `#42` is the run's PR number where one -exists, the branch name when it doesn't (push/fork-triggered runs), or -dropped entirely (along with its leading space) when neither is -available. Long text scrolls, same as the running badge. Each failing or +Failure, waiting, and healthy frames share a dark two-row card. A fixed +`CI FAIL`, `CI WAIT`, or `CI OK` heading stays visible while the bold lower +row scrolls the complete `owner/repo #42 workflow` context. Coral, amber, and +mint divider lines distinguish the states; firmware 1.2.3 adds a matching +7×7 status icon in the header. The healthy card reads `ALL CLEAR` without +scrolling. The PR number is replaced by the branch for push/fork runs, or +omitted if neither exists. Each failing or stuck run gets its own frame in the rotation — with several failures or stuck runs across repos, expect several red/amber frames in a row before the rotation reaches the running badge or quota frames. @@ -190,17 +190,15 @@ frames, if any: 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 + **When `running_spinner` is true (default)**, the native 8×8 spinner + occupies the lower-right corner. The title keeps its full 68-pixel width; + the ETA and its label have a separate 60-pixel budget. A cyan-to-mint track + and bright endpoint show elapsed progress. The device animates between + ordinary polls; no host animation loop is added. +2. **GraphQL quota** (`show_quota`): title ribbon `GQL LEFT` with a separate `RESET` label, 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`). -3. **REST quota** (`show_quota`): identical layout, title ribbon `GITHUB REST`. +3. **REST quota** (`show_quota`): identical layout, title ribbon `REST LEFT` with the same `RESET` label. Each quota frame is built from a single `GET /rate_limit` call, fetched fresh once per `running_poll_seconds` cycle while a run is active — this diff --git a/integrations/ci_status/logic.py b/integrations/ci_status/logic.py index b4bce5e..9555a09 100644 --- a/integrations/ci_status/logic.py +++ b/integrations/ci_status/logic.py @@ -198,14 +198,11 @@ def evaluate_runs(repo: str, runs: list[dict], now: datetime, # 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). +# an animated 8x8 stock asset in the lower-right corner. The title keeps the +# full 68px ribbon; only ETA text narrows to leave the spinner a clear lane. 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_ETA_WIDTH_SPINNER = 60 # --- running-job badge ----------------------------------------------------------- @@ -216,11 +213,11 @@ def evaluate_runs(repo: str, runs: list[dict], now: datetime, # The track's own groove color is a decorative element, not a text-bearing # surface, so it's allowed to be a touch brighter than the panel background # without violating that rule -- same treatment as the calendar's TRACK_COLOR. -RUNNING_BG_GRADIENT = ["#031A2EFF", "#00060DFF"] -RUNNING_TITLE_COLOR = "#7FDBFFFF" -RUNNING_TRACK_COLOR = "#0F2A42FF" -RUNNING_TRACK_FILL_COLOR = "#29B6F6FF" # spec: "solid cyan" -- one flat color, no gradient -RUNNING_NUMERAL_COLOR = "#66E1FFFF" +RUNNING_BG_GRADIENT = ["#081426FF", "#020611FF"] +RUNNING_TITLE_COLOR = "#FFFFFFFF" +RUNNING_TRACK_COLOR = "#13243BFF" +RUNNING_TRACK_FILL_COLORS = ["#29D6FFFF", "#5CFFB1FF"] +RUNNING_NUMERAL_COLOR = "#8CFFFFFF" # ETA label (v1.5.1): a muted gray-blue, deliberately desaturated and # dimmer than the bright cyan numeral it sits beside -- a secondary-tier @@ -308,7 +305,7 @@ def _format_eta_text(run: dict, median_minutes: float | None, now: datetime) -> return f"~{_format_countdown(eta)}" -def _eta_label(eta_text: str) -> str | None: +def _eta_label(eta_text: str, *, budget: int = RUNNING_LABEL_BUDGET_PX) -> str | None: """"remain" / "left" / None, appended after a remaining-estimate ETA numeral (v1.5.1). @@ -339,7 +336,7 @@ def _eta_label(eta_text: str) -> str | None: return None eta_width = _text_width_px(eta_text) for label in ("remain", "left"): - if eta_width + RUNNING_LABEL_GAP_PX + _text_width_px(label) <= RUNNING_LABEL_BUDGET_PX: + if eta_width + RUNNING_LABEL_GAP_PX + _text_width_px(label) <= budget: return label return None @@ -377,18 +374,16 @@ def _build_running_elements(info: RunningInfo, timeout_s: int, show_spinner: boo 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. + `show_spinner` (v1.6, config-gated): when true, leaves the full title + ribbon intact, limits ETA text to 60px, and appends an animated spinner + in the lower-right corner. 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 + eta_width = RUNNING_ETA_WIDTH_SPINNER if show_spinner else None bg_element = { "id": "bg", "type": "rectangle", "x": 0, "y": 0, @@ -399,9 +394,9 @@ def _build_running_elements(info: RunningInfo, timeout_s: int, show_spinner: boo title_element = { "id": "title", "type": "text", "text": title_text, "font": "small", "color": RUNNING_TITLE_COLOR, "x": RUNNING_TITLE_X, "y": RUNNING_TITLE_Y, - "width": title_width, "timeout": timeout_s, + "width": RUNNING_TITLE_WIDTH, "timeout": timeout_s, } - if not _title_fits(title_text, title_width): + if not _title_fits(title_text, RUNNING_TITLE_WIDTH): title_element.update({ "scroll_rate": SCROLL_RATE, "scroll_start_delay": SCROLL_DELAY_MS, @@ -414,15 +409,23 @@ def _build_running_elements(info: RunningInfo, timeout_s: int, show_spinner: boo } track_fill_element = { "id": "track_fill", "type": "rectangle", "x": 0, "y": RUNNING_TRACK_Y, - "width": track_width, "height": RUNNING_TRACK_HEIGHT, "fill": "solid", - "fill_colors": [RUNNING_TRACK_FILL_COLOR], "border_width": 0, "timeout": timeout_s, + "width": track_width, "height": RUNNING_TRACK_HEIGHT, "fill": "gradient_h", + "fill_colors": RUNNING_TRACK_FILL_COLORS, "border_width": 0, "timeout": timeout_s, } numeral_element = { "id": "eta", "type": "text", "text": eta_text, "font": "large", "color": RUNNING_NUMERAL_COLOR, "x": RUNNING_NUMERAL_X, "y": RUNNING_NUMERAL_Y, "timeout": timeout_s, } - elements = [bg_element, title_element, track_element, track_fill_element, numeral_element] + if eta_width is not None: + numeral_element["width"] = eta_width + tip_width = min(2, track_width) + tip_element = { + "id": "track_tip", "type": "rectangle", "x": track_width - tip_width, + "y": RUNNING_TRACK_Y, "width": tip_width, "height": RUNNING_TRACK_HEIGHT, + "fill": "solid", "fill_colors": ["#FFFFFFFF"], "border_width": 0, "timeout": timeout_s, + } + elements = [bg_element, title_element, track_element, track_fill_element, tip_element, numeral_element] # ETA label (v1.5.1): appended only when _eta_label decides it fits # (grammar guard + width check -- see its docstring). This changes @@ -432,7 +435,7 @@ def _build_running_elements(info: RunningInfo, timeout_s: int, show_spinner: boo # for that: main.py's unified shape tracker (frozenset of element # ids on whatever was last actually drawn) already detects any shape # change and clears first, generically, not just at tier boundaries. - label = _eta_label(eta_text) + label = _eta_label(eta_text, budget=eta_width or RUNNING_LABEL_BUDGET_PX) if label is not None: elements.append({ "id": "eta_label", "type": "text", "text": label, "font": "small", @@ -443,7 +446,7 @@ def _build_running_elements(info: RunningInfo, timeout_s: int, show_spinner: boo if show_spinner: elements.append({"id": RUN_SPINNER_ID, "type": "animation", "stock_path": SPINNER_STOCK, - "x": 64, "y": 0, "loop": True, "timeout": timeout_s}) + "x": 64, "y": 8, "loop": True, "timeout": timeout_s}) return elements @@ -472,14 +475,14 @@ def _build_running_elements(info: RunningInfo, timeout_s: int, show_spinner: boo QUOTA_TRACK_COLOR = "#12241EFF" QUOTA_BG_GRADIENT = { - QUOTA_HEADROOM_HIGH: ["#031F17FF", "#000A08FF"], - QUOTA_HEADROOM_MEDIUM: ["#231400FF", "#0A0400FF"], - QUOTA_HEADROOM_LOW: ["#2E0509FF", "#0A0101FF"], + QUOTA_HEADROOM_HIGH: ["#081B23FF", "#020611FF"], + QUOTA_HEADROOM_MEDIUM: ["#211707FF", "#020611FF"], + QUOTA_HEADROOM_LOW: ["#240A10FF", "#020611FF"], } QUOTA_TITLE_COLOR = { - QUOTA_HEADROOM_HIGH: "#6FFFCFFF", - QUOTA_HEADROOM_MEDIUM: "#FFCB6BFF", - QUOTA_HEADROOM_LOW: "#FF6B7AFF", + QUOTA_HEADROOM_HIGH: "#FFFFFFFF", + QUOTA_HEADROOM_MEDIUM: "#FFFFFFFF", + QUOTA_HEADROOM_LOW: "#FFFFFFFF", } QUOTA_TRACK_FILL_COLOR = { QUOTA_HEADROOM_HIGH: "#33FFC1FF", @@ -494,9 +497,9 @@ def _build_running_elements(info: RunningInfo, timeout_s: int, show_spinner: boo # Two numerals share the row (percentage remaining on the left, reset-in on # the right) -- the only overlay frame that does, since the running badge -# has just one. No divider element between them (the brief didn't ask for -# one); RESET_X leaves both enough room for their respective worst cases -# ("100%" on the left, an hours-form countdown on the right). +# has just one. A thin accent separator at x=35 makes the split legible; +# RESET_X leaves both enough room for their respective worst cases ("100%" +# on the left, an hours-form countdown on the right). QUOTA_PCT_X = OVERLAY_TITLE_X # 2 -- same left margin as every other overlay element QUOTA_RESET_X = 40 @@ -574,18 +577,15 @@ def _build_quota_elements(info: QuotaInfo, timeout_s: int) -> list[dict]: "fill": "gradient_v", "fill_colors": QUOTA_BG_GRADIENT[headroom], "border_width": 0, "timeout": timeout_s, } - title_text = ascii_safe(info.label).upper() + title_text = "GQL LEFT" if "GRAPHQL" in info.label.upper() else "REST LEFT" title_element = { "id": "title", "type": "text", "text": title_text, "font": "small", "color": QUOTA_TITLE_COLOR[headroom], "x": OVERLAY_TITLE_X, "y": OVERLAY_TITLE_Y, - "width": OVERLAY_TITLE_WIDTH, "timeout": timeout_s, + "width": 38, "timeout": timeout_s, } - if not _title_fits(title_text, OVERLAY_TITLE_WIDTH): - title_element.update({ - "scroll_rate": SCROLL_RATE, - "scroll_start_delay": SCROLL_DELAY_MS, - "scroll_repeat_delay": SCROLL_DELAY_MS, - }) + reset_label = {"id": "reset_label", "type": "text", "text": "RESET", "font": "small", + "color": "#FFFFFFFF", "x": 45, "y": OVERLAY_TITLE_Y, "width": 25, + "timeout": timeout_s} track_element = { "id": "track", "type": "rectangle", "x": 0, "y": OVERLAY_TRACK_Y, "width": PANEL_WIDTH, "height": OVERLAY_TRACK_HEIGHT, "fill": "solid", @@ -606,7 +606,12 @@ def _build_quota_elements(info: QuotaInfo, timeout_s: int) -> list[dict]: "color": QUOTA_NUMERAL_COLOR[headroom], "x": QUOTA_RESET_X, "y": OVERLAY_NUMERAL_Y, "timeout": timeout_s, } - return [bg_element, title_element, track_element, track_fill_element, pct_element, reset_element] + separator = {"id": "quota_separator", "type": "rectangle", "x": 35, "y": 8, + "width": 1, "height": 7, "fill": "solid", + "fill_colors": [QUOTA_TRACK_FILL_COLOR[headroom]], "border_width": 0, + "timeout": timeout_s} + return [bg_element, title_element, reset_label, track_element, track_fill_element, + pct_element, separator, reset_element] # --- overlay rotation -------------------------------------------------------------- @@ -682,15 +687,16 @@ def build_overlay_payload(descriptor: dict, timeout_s: int, *, return {"elements": _build_quota_elements(info, timeout_s), "priority": PRIORITY_OVERLAY, "led": None} if kind == OVERLAY_FRAME_FAIL: - text = "CI FAIL " + _fail_line(descriptor["repo"], descriptor["run"]) - return {"elements": _badge_elements(text, "#A32D2DFF", "#FFFFFFFF", timeout_s), + text = ascii_safe(_fail_line(descriptor["repo"], descriptor["run"])) + return {"elements": _badge_elements("CI FAIL", text, "#FF7A8AFF", timeout_s), "priority": PRIORITY_OVERLAY, "led": None} if kind == OVERLAY_FRAME_STUCK: - text = "CI stuck " + _fail_line(descriptor["repo"], descriptor["run"]) - return {"elements": _badge_elements(text, "#BA7517FF", "#0B0B0BFF", timeout_s), + text = ascii_safe(_fail_line(descriptor["repo"], descriptor["run"])) + return {"elements": _badge_elements("CI WAIT", text, "#FFCB6BFF", timeout_s), "priority": PRIORITY_OVERLAY, "led": None} if kind == OVERLAY_FRAME_GREEN: - return {"elements": [_text_element("CI ok", "#00FF00FF", timeout_s)], + return {"elements": _badge_elements("CI OK", "ALL CLEAR", "#6FFFCFFF", timeout_s, + scroll_body=False), "priority": PRIORITY_OVERLAY, "led": None} return None @@ -727,17 +733,26 @@ def _text_element(text: str, color: str, timeout_s: int, font: str = "normal") - def _fail_line(repo: str, fr: FailingRun) -> str: - """"owner/repo #42 · workflow" (the ref is dropped when empty).""" + """"owner/repo #42 workflow" (the ref is dropped when empty).""" ref = f" {fr.ref}" if fr.ref else "" - return f"{repo}{ref} · {fr.workflow}" + return f"{repo}{ref} {fr.workflow}" -def _badge_elements(text: str, bg_color: str, text_color: str, timeout_s: int) -> list[dict]: - """Full-panel rounded-rect background + bold scrolling text over it.""" +def _badge_elements(header: str, text: str, accent_color: str, timeout_s: int, *, + scroll_body: bool = True) -> list[dict]: + """Two-row CI status card with a fixed header and full context body.""" # border_width=0: RectangleElement defaults to a 1px white border, which # would draw an unwanted white outline around the badge (verified on-device). bg = {"id": "bg", "type": "rectangle", "x": 0, "y": 0, "width": 72, "height": 16, - "radius": 2, "fill": "solid", "fill_colors": [bg_color], "border_width": 0, + "fill": "gradient_v", "fill_colors": ["#111A2BFF", "#020611FF"], "border_width": 0, "timeout": timeout_s} - return [bg, _text_element(text, text_color, timeout_s, font="bold")] - + header_element = {"id": "ci_header", "type": "text", "text": header, "font": "small", + "color": "#FFFFFFFF", "x": 2, "y": -2, "width": 68, "timeout": timeout_s} + rule = {"id": "ci_rule", "type": "rectangle", "x": 12, "y": 6, "width": 58, "height": 1, + "fill": "solid", "fill_colors": [accent_color], "border_width": 0, "timeout": timeout_s} + body = {"id": "ci", "type": "text", "text": text, "font": "bold", "color": "#FFFFFFFF", + "x": 2, "y": 6, "width": 68, "timeout": timeout_s} + if scroll_body: + body.update({"scroll_rate": 2000, "scroll_start_delay": 1000, + "scroll_repeat_delay": 2000}) + return [bg, header_element, rule, body] diff --git a/integrations/ci_status/main.py b/integrations/ci_status/main.py index e918b7e..99bfe74 100644 --- a/integrations/ci_status/main.py +++ b/integrations/ci_status/main.py @@ -265,7 +265,10 @@ def run_once(client, poller, cfg: dict, now: datetime, overlay_state["frame_index"] = frame_index + 1 overlay_state["last_dwell_end"] = now + timedelta(seconds=OVERLAY_DWELL_SECONDS) - text = next(e["text"] for e in payload["elements"] if e["type"] == "text") + text = next(e["text"] for e in payload["elements"] + if e["type"] == "text" and e["id"] != "ci_header") + header = next((e["text"] for e in payload["elements"] if e["id"] == "ci_header"), "") + text = f"{header} {text}".strip() return f"{text[:40]!r} -> {result.value}" diff --git a/src/busybar/presentation.py b/src/busybar/presentation.py index bb909a3..ffacf5d 100644 --- a/src/busybar/presentation.py +++ b/src/busybar/presentation.py @@ -70,7 +70,7 @@ def commit_frame(state: dict | None, elements: list[dict], priority: int, def ci_bitmap_accent(elements: list[dict], kind: str, timeout_s: int) -> list[dict]: - """Add an inline 7x7 status symbol beside the existing scrolling CI text. + """Add an inline 7x7 status symbol beside the fixed CI header. These are fixed local assets, not a general image parser. The text remains the meaningful fallback if the client must use an older/cloud endpoint mid-draw. @@ -78,10 +78,10 @@ def ci_bitmap_accent(elements: list[dict], kind: str, timeout_s: int) -> list[di rows = _ICON_ROWS.get(kind) if rows is None: return elements - colors = {"fail": "#FFFFFF", "stuck": "#0B0B0B", "green": "#00FF00"} + colors = {"fail": "#FFFFFF", "stuck": "#FFCB6B", "green": "#6FFFCF"} data = "! XPM2\n7 7 2 1\n. c None\nX c " + colors[kind] + "\n" + "\n".join(rows) + "\n" - frame = [{**element, "x": 11, "width": 61} if element["id"] == "ci" else dict(element) + frame = [{**element, "x": 12, "width": 58} if element["id"] == "ci_header" else dict(element) for element in elements] frame.append({"id": "ci_status_icon", "type": "xpmbitmap", "data": data, - "x": 1, "y": 4, "timeout": timeout_s}) + "x": 2, "y": 0, "timeout": timeout_s}) return frame diff --git a/tests/test_calendar_logic.py b/tests/test_calendar_logic.py index 9a8842d..96d88bf 100644 --- a/tests/test_calendar_logic.py +++ b/tests/test_calendar_logic.py @@ -1,3 +1,4 @@ +import pytest from datetime import datetime, timedelta, timezone import sys from pathlib import Path @@ -213,11 +214,11 @@ def test_build_elements_upcoming_shape(): els = build_elements(e, NOW, CFG, timeout_s=90, in_progress=False) by_id = {el["id"]: el for el in els} # v1.4 "airy": no card elements -- time_card and cd_card are gone. - assert set(by_id) == {"bg", "title", "track", "track_fill", "time", "divider", "cd_text"} + assert set(by_id) == {"bg", "title", "track", "track_fill", "track_tip", "time", "divider", "cd_text"} # Draw order is z-order (first = behind); set-membership alone wouldn't # catch a reorder that visually breaks layering. assert [el["id"] for el in els] == [ - "bg", "title", "track", "track_fill", "time", "divider", "cd_text", + "bg", "title", "track", "track_fill", "track_tip", "time", "divider", "cd_text", ] bg = by_id["bg"] @@ -325,12 +326,12 @@ def test_build_elements_in_progress_shape(): e = ev(-5, dur_min=30, title="Standup") els = build_elements(e, NOW, CFG, timeout_s=90, in_progress=True) by_id = {el["id"]: el for el in els} - assert set(by_id) == {"bg", "title", "track", "track_fill", "ends", "divider", "cd_text"} + assert set(by_id) == {"bg", "title", "track", "track_fill", "track_tip", "ends", "divider", "cd_text"} assert "time" not in by_id # no start-time label while in progress # Draw order is z-order (first = behind); see the upcoming-shape test # for why set-membership alone isn't enough to catch a reorder. assert [el["id"] for el in els] == [ - "bg", "title", "track", "track_fill", "ends", "divider", "cd_text", + "bg", "title", "track", "track_fill", "track_tip", "ends", "divider", "cd_text", ] bg = by_id["bg"] @@ -341,9 +342,9 @@ def test_build_elements_in_progress_shape(): assert title["text"] == "STANDUP" track_fill = by_id["track_fill"] - assert track_fill["fill"] == "solid" - assert track_fill["fill_colors"] == [TRACK_FILL_IN_PROGRESS] - assert track_fill["width"] == PANEL_WIDTH # no drain while in progress + assert track_fill["fill"] == "gradient_h" + assert track_fill["fill_colors"][-1] == TRACK_FILL_IN_PROGRESS + assert track_fill["width"] == 60 # 25 of 30 minutes remaining ends = by_id["ends"] assert ends["type"] == "text" and ends["font"] == "bold" @@ -763,30 +764,27 @@ def test_icon_present_drops_time_element(): 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 - 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_warning_title_uses_full_reserved_lane_and_scrolls_only_when_needed(): + event = _ev(NOW2 + timedelta(minutes=4)) + frame = {e["id"]: e for e in build_elements(event, NOW2, _cfg(), 15, False)} + assert frame["title"]["text"] == "STANDUP" + assert (frame["title"]["x"], frame["title"]["width"]) == (18, 52) + assert "scroll_rate" not in frame["title"] + event.title = "Quarterly planning review" + frame = {e["id"]: e for e in build_elements(event, NOW2, _cfg(), 15, False)} + assert frame["title"]["scroll_rate"] == SCROLL_RATE + assert frame["title"]["scroll_start_delay"] == SCROLL_DELAY_MS + + +def test_imminent_keeps_event_context_and_reads_less_than_one_minute(): + event = _ev(NOW2 + timedelta(seconds=30)) + frame = {e["id"]: e for e in build_elements(event, NOW2, _cfg(), 15, False)} + assert frame[CAL_ICON_ID]["stock_path"] == f"shared/{ICON_REMINDER}.anim" + assert frame["title"]["text"] == "STANDUP" + assert frame["starts_label"]["text"] == "IN" + assert frame["cd_text"]["text"] == "<1m" + assert _format_countdown(.5) == "0m" # shared CI formatting is unchanged + def test_just_started_returns_takeover_animation(): ev = _ev(NOW2 - timedelta(seconds=10)) @@ -799,3 +797,25 @@ 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) + + +@pytest.mark.parametrize("minutes", [0, .01, .5, 1, 4, 15, 60, 90]) +def test_progress_highlight_and_warning_lane_stay_within_track(minutes): + event = _ev(NOW2 + timedelta(minutes=minutes)) + frame = {e["id"]: e for e in build_elements(event, NOW2, _cfg(), 15, False)} + track, fill, tip = (frame[k] for k in ("track", "track_fill", "track_tip")) + assert track["x"] <= fill["x"] <= tip["x"] + assert tip["x"] + tip["width"] <= fill["x"] + fill["width"] <= track["x"] + track["width"] <= 72 + assert tip["height"] == 1 and tip["border_width"] == 0 + assert all(e["timeout"] == 15 for e in frame.values()) + if CAL_ICON_ID in frame: + assert track["x"] >= 18 and frame["title"]["x"] >= 18 + + +def test_active_event_progress_drains_and_handles_invalid_duration(): + event = ev(-15, dur_min=30) + frame = {e["id"]: e for e in build_elements(event, NOW, CFG, 15, True)} + assert frame["track_fill"]["width"] == 36 + event.end = event.start + frame = {e["id"]: e for e in build_elements(event, NOW, CFG, 15, True)} + assert frame["track_fill"]["width"] == 72 diff --git a/tests/test_calendar_loop.py b/tests/test_calendar_loop.py index 6d209a2..0dd34e5 100644 --- a/tests/test_calendar_loop.py +++ b/tests/test_calendar_loop.py @@ -46,7 +46,7 @@ def test_draws_countdown_for_upcoming_event(): assert kwargs["priority"] == PRIORITY_AMBIENT == 20 by_id = {el["id"]: el for el in kwargs["elements"]} # v1.4 "airy": no card elements. - assert set(by_id) == {"bg", "title", "track", "track_fill", "time", "divider", "cd_text"} + assert set(by_id) == {"bg", "title", "track", "track_fill", "track_tip", "time", "divider", "cd_text"} assert by_id["cd_text"]["text"] == _format_countdown((event.start - NOW).total_seconds() / 60) assert by_id["title"]["text"] == "STANDUP" # uppercased after ascii_safe assert "drew" in summary @@ -59,7 +59,7 @@ def test_draws_in_progress_event_targeting_active_over_upcoming(): summary = run_once(client, lambda hours: [active, upcoming], CFG, NOW, dry_run=False) kwargs = client.draw.call_args.kwargs by_id = {el["id"]: el for el in kwargs["elements"]} - assert set(by_id) == {"bg", "title", "track", "track_fill", "ends", "divider", "cd_text"} + assert set(by_id) == {"bg", "title", "track", "track_fill", "track_tip", "ends", "divider", "cd_text"} assert by_id["title"]["text"] == "ACTIVE" assert by_id["cd_text"]["text"] == _format_countdown((active.end - NOW).total_seconds() / 60) assert "time" not in by_id @@ -98,7 +98,7 @@ def test_no_clear_on_first_draw_with_fresh_state(): # (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"}) + {"bg", "title", "track", "track_fill", "track_tip", "time", "divider", "cd_text"}) def test_no_clear_across_polls_with_same_state(): client = Mock() @@ -118,7 +118,7 @@ def test_clears_on_upcoming_to_in_progress_transition(): client.clear.assert_called_once_with("calendar_countdown") # 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"}) + {"bg", "title", "track", "track_fill", "track_tip", "ends", "divider", "cd_text"}) def test_clears_on_in_progress_to_upcoming_transition(): client = Mock() @@ -148,8 +148,8 @@ 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"}) + upcoming_shape = frozenset({"bg", "title", "track", "track_fill", "track_tip", "time", "divider", "cd_text"}) + in_progress_shape = frozenset({"bg", "title", "track", "track_fill", "track_tip", "ends", "divider", "cd_text"}) run_once(client, lambda hours: [make_event(23)], CFG, NOW, dry_run=False, state=state) assert state["last_shape"] == upcoming_shape @@ -187,7 +187,7 @@ def test_clear_failure_does_not_block_state_commit_when_draw_succeeds(): run_once(client, lambda hours: [active], CFG, NOW, dry_run=False, state=state) client.clear.assert_called_once_with("calendar_countdown") assert state["last_shape"] == frozenset( - {"bg", "title", "track", "track_fill", "ends", "divider", "cd_text"}) + {"bg", "title", "track", "track_fill", "track_tip", "ends", "divider", "cd_text"}) def test_state_reset_after_no_event_clear(): client = Mock() @@ -541,7 +541,7 @@ def test_shape_change_triggers_clear(): # FakeClient that returns a chosen DrawResult for the takeover draw (the one # carrying START_ANIM_ID) and DRAWN for everything else. -IN_PROGRESS_SHAPE = frozenset({"bg", "title", "track", "track_fill", "ends", "divider", "cd_text"}) +IN_PROGRESS_SHAPE = frozenset({"bg", "title", "track", "track_fill", "track_tip", "ends", "divider", "cd_text"}) class ResultFakeClient: diff --git a/tests/test_ci_logic.py b/tests/test_ci_logic.py index 6337711..49dbbe1 100644 --- a/tests/test_ci_logic.py +++ b/tests/test_ci_logic.py @@ -254,10 +254,11 @@ def test_overlay_ci_badge_shape(): # v1.5.1: a fitting remain-estimate ETA ("~11m" here) picks up the # "eta_label" element too -- see test_eta_label_* below for the full # fit-decision and grammar-guard coverage. - assert set(by_id) == {"bg", "title", "track", "track_fill", "eta", "eta_label"} + assert set(by_id) == {"bg", "title", "track", "track_fill", "track_tip", "eta", "eta_label"} assert [e["id"] for e in payload["elements"]] == \ - ["bg", "title", "track", "track_fill", "eta", "eta_label"] + ["bg", "title", "track", "track_fill", "track_tip", "eta", "eta_label"] assert by_id["eta_label"]["text"] == "remain" + assert all(element["timeout"] == OVERLAY_DWELL_SECONDS for element in by_id.values()) bg = by_id["bg"] assert bg["fill"] == "gradient_v" and bg["border_width"] == 0 @@ -271,8 +272,14 @@ def test_overlay_ci_badge_shape(): assert track["y"] == 6 and track["width"] == 72 and track["border_width"] == 0 track_fill = by_id["track_fill"] - assert track_fill["fill"] == "solid" # spec: "solid cyan", no gradient + assert track_fill["fill"] == "gradient_h" + assert track_fill["fill_colors"] == ["#29D6FFFF", "#5CFFB1FF"] assert track_fill["width"] == _progress_width(3, 14) + tip = by_id["track_tip"] + assert tip["fill_colors"] == ["#FFFFFFFF"] and tip["height"] == 1 + assert tip["width"] == min(2, track_fill["width"]) + assert tip["x"] == track_fill["x"] + track_fill["width"] - tip["width"] + assert tip["timeout"] == OVERLAY_DWELL_SECONDS eta = by_id["eta"] assert eta["font"] == "large" and eta["y"] == 5 # numeral-floor rule: large font @@ -286,7 +293,7 @@ def test_overlay_ci_badge_shape_no_history_has_no_label(): info = running_info(run=run_, median_minutes=None) payload = build_overlay_payload({"kind": OVERLAY_FRAME_CI_BADGE}, OVERLAY_DWELL_SECONDS, running=info) by_id = _by_id(payload["elements"]) - assert set(by_id) == {"bg", "title", "track", "track_fill", "eta"} + assert set(by_id) == {"bg", "title", "track", "track_fill", "track_tip", "eta"} assert by_id["eta"]["text"] == "3m in" def test_overlay_ci_badge_title_scrolls_when_long(): @@ -310,25 +317,32 @@ def test_overlay_quota_gql_shape(): assert payload["priority"] == PRIORITY_OVERLAY by_id = _by_id(payload["elements"]) - assert set(by_id) == {"bg", "title", "track", "track_fill", "pct", "reset"} + assert set(by_id) == {"bg", "title", "reset_label", "track", "track_fill", "pct", "quota_separator", "reset"} assert [e["id"] for e in payload["elements"]] == \ - ["bg", "title", "track", "track_fill", "pct", "reset"] + ["bg", "title", "reset_label", "track", "track_fill", "pct", "quota_separator", "reset"] - assert by_id["title"]["text"] == "GITHUB GRAPHQL" - assert by_id["title"]["font"] == "small" + title = by_id["title"] + assert title["text"] == "GQL LEFT" and title["font"] == "small" + assert (title["x"], title["y"], title["width"]) == (2, -2, 38) + reset_label = by_id["reset_label"] + assert reset_label["text"] == "RESET" and reset_label["font"] == "small" + assert (reset_label["x"], reset_label["y"], reset_label["width"]) == (45, -2, 25) assert by_id["pct"]["text"] == "52%" # floor(2600/5000*100) = 52 assert by_id["pct"]["font"] == "large" # numeral-floor rule assert by_id["reset"]["text"] == "42m" assert by_id["reset"]["font"] == "large" assert by_id["track_fill"]["width"] == _quota_used_width(2400, 5000) assert by_id["track"]["y"] == 6 and by_id["track"]["border_width"] == 0 + assert by_id["quota_separator"]["x"] == 35 and by_id["quota_separator"]["y"] == 8 + assert by_id["quota_separator"]["width"] == 1 and by_id["quota_separator"]["height"] == 7 + assert all(element["timeout"] == OVERLAY_DWELL_SECONDS for element in by_id.values()) def test_overlay_quota_rest_uses_core_bucket(): info = quota_info(label="GITHUB REST", limit=5000, remaining=100, used=4900) payload = build_overlay_payload({"kind": OVERLAY_FRAME_QUOTA_REST}, OVERLAY_DWELL_SECONDS, quota_by_bucket={"core": info}) by_id = _by_id(payload["elements"]) - assert by_id["title"]["text"] == "GITHUB REST" + assert by_id["title"]["text"] == "REST LEFT" assert by_id["pct"]["text"] == "2%" def test_overlay_quota_none_when_bucket_missing(): @@ -344,30 +358,43 @@ def test_overlay_quota_none_when_bucket_missing(): # --- build_overlay_payload: fail/stuck/green frames (descriptor dispatch) ------- -def test_fail_frame_is_red_badge_at_overlay_tier(): +def test_fail_frame_has_coral_divider_and_full_context_at_overlay_tier(): d = {"kind": OVERLAY_FRAME_FAIL, "repo": "o/r", "run": FailingRun("tests", "#42")} payload = build_overlay_payload(d, OVERLAY_DWELL_SECONDS) assert payload["priority"] == PRIORITY_OVERLAY - assert _bg_element(payload["elements"])["fill_colors"] == ["#A32D2DFF"] - t = _text_element(payload["elements"]) - assert t["text"] == "CI FAIL o/r #42 · tests" and t["color"] == "#FFFFFFFF" + by_id = _by_id(payload["elements"]) + assert by_id["bg"]["fill_colors"] == ["#111A2BFF", "#020611FF"] + header = by_id["ci_header"] + assert header["text"] == "CI FAIL" and header["font"] == "small" + assert (header["x"], header["y"], header["width"]) == (2, -2, 68) + assert "scroll_rate" not in header + assert by_id["ci_rule"]["fill_colors"] == ["#FF7A8AFF"] + body = by_id["ci"] + assert body["text"] == "o/r #42 tests" and body["font"] == "bold" + assert (body["x"], body["y"], body["width"]) == (2, 6, 68) + assert all(element["timeout"] == OVERLAY_DWELL_SECONDS for element in by_id.values()) def test_fail_frame_drops_ref_when_empty(): d = {"kind": OVERLAY_FRAME_FAIL, "repo": "o/r", "run": FailingRun("tests", "")} - assert _text_element(build_overlay_payload(d, 10)["elements"])["text"] == "CI FAIL o/r · tests" + assert _by_id(build_overlay_payload(d, 10)["elements"])["ci"]["text"] == "o/r tests" def test_stuck_frame_is_amber_badge_at_overlay_tier(): d = {"kind": OVERLAY_FRAME_STUCK, "repo": "o/r", "run": FailingRun("deploy", "#7")} payload = build_overlay_payload(d, 10) assert payload["priority"] == PRIORITY_OVERLAY - assert _bg_element(payload["elements"])["fill_colors"] == ["#BA7517FF"] - assert _text_element(payload["elements"])["text"] == "CI stuck o/r #7 · deploy" + by_id = _by_id(payload["elements"]) + assert by_id["ci_header"]["text"] == "CI WAIT" + assert by_id["ci_rule"]["fill_colors"] == ["#FFCB6BFF"] + assert by_id["ci"]["text"] == "o/r #7 deploy" -def test_green_frame_is_quiet_text_at_overlay_tier(): +def test_green_frame_is_static_mint_card_at_overlay_tier(): payload = build_overlay_payload({"kind": OVERLAY_FRAME_GREEN}, 10) assert payload["priority"] == PRIORITY_OVERLAY - assert _text_element(payload["elements"])["color"] == "#00FF00FF" - assert not any(e["type"] == "rectangle" for e in payload["elements"]) + by_id = _by_id(payload["elements"]) + assert by_id["ci_header"]["text"] == "CI OK" + assert by_id["ci_rule"]["fill_colors"] == ["#6FFFCFFF"] + assert by_id["ci"]["text"] == "ALL CLEAR" + assert "scroll_rate" not in by_id["ci"] def test_sequence_orders_fail_then_stuck_then_badge_then_quota(): states = [RepoState("o/r", [FailingRun("a", "#1")], [FailingRun("b", "#2")])] @@ -560,6 +587,12 @@ def test_eta_label_falls_back_to_left_when_remain_does_not_fit(): assert _text_width_px("~1h00m") == 40 assert _eta_label("~1h00m") == "left" +def test_eta_label_uses_spinner_budget_when_requested(): + # The spinner shares the lower-right lane. At its 60px ETA budget, + # "remain" no longer fits beside a short ETA, while "left" still does. + assert _eta_label("~59m", budget=60) == "left" + assert _eta_label("~1h00m", budget=60) is None + def test_eta_label_omitted_when_neither_fits(): # Synthetic, deliberately-wide input -- _format_eta_text/_format_countdown # never actually produce a string this wide in practice (the h+mm full @@ -615,14 +648,15 @@ def _running(): repo="me/repo", other_count=0, median_minutes=8.0, now=SPINNER_NOW) -def test_spinner_present_and_title_reserved_when_on(): +def test_spinner_preserves_full_title_and_narrows_eta_when_on(): p = build_overlay_payload({"kind": 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 + assert spin["x"] == 64 and spin["y"] == 8 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 + assert title["width"] == 68 + assert next(e for e in els if e["id"] == "eta")["width"] == 60 def test_no_spinner_and_full_title_when_off(): p = build_overlay_payload({"kind": OVERLAY_FRAME_CI_BADGE}, 10, running=_running(), show_spinner=False) diff --git a/tests/test_ci_loop.py b/tests/test_ci_loop.py index e8971e3..a6d2823 100644 --- a/tests/test_ci_loop.py +++ b/tests/test_ci_loop.py @@ -29,9 +29,9 @@ def _run(conclusion: str) -> dict: "conclusion": conclusion, "created_at": "2026-08-03T13:30:00Z"} -def _drawn_text(client) -> str: +def _drawn_texts(client) -> list[str]: els = client.draw.call_args.kwargs.get("elements") or client.draw.call_args.args[1] - return next(e["text"] for e in els if e.get("type") == "text") + return [e["text"] for e in els if e.get("type") == "text"] def test_draws_red_on_failure(): @@ -40,7 +40,8 @@ def test_draws_red_on_failure(): summary = run_once(client, poller, CFG, NOW, {}, dry_run=False) assert client.draw.call_args.kwargs["priority"] == PRIORITY_OVERLAY # 21, not 60 assert client.draw.call_args.kwargs["led_notification_color"] == "#FF0000FF" - assert "FAIL" in summary + assert "CI FAIL" in summary + assert CFG["ci_status"]["repos"][0] in summary def test_failure_rotates_with_no_running_job(): @@ -95,7 +96,8 @@ def test_green_folds_into_rotation_at_overlay_tier(): cfg = {"ci_status": {**CFG["ci_status"], "show_green": True}} run_once(client, poller, cfg, NOW, {}, dry_run=False, overlay_state={}) assert client.draw.call_args.kwargs["priority"] == PRIORITY_OVERLAY - assert "ok" in _drawn_text(client) + assert "CI OK" in _drawn_texts(client) + assert "ALL CLEAR" in _drawn_texts(client) def test_clears_when_green(): @@ -296,10 +298,10 @@ def test_overlay_state_resets_when_run_ends(): # --- unified shape tracking across alert / quiet-green / overlay tiers ---------- def test_overlay_then_alert_clears_stale_overlay_shape(): - # Running badge draws first (shape {bg,title,track,track_fill,eta}); + # Running badge draws first (shape {bg,title,track,track_fill,track_tip,eta}); # the next poll turns up a failure. Once the rotation's next dwell slot - # lands on the failure frame (shape {bg,ci}), the stale - # title/track/track_fill/eta ink from the badge must be cleared first -- + # lands on the failure frame (shape {bg,ci_header,ci_rule,ci}), the stale + # title/track/track_fill/track_tip/eta ink from the badge must clear first -- # not left to linger until its own ~1.5x-poll timeout. Both the failure # frame and the running badge are now part of the SAME dwell-gated # rotation (Request B), so `frame_index` is pinned to 0 to deterministically @@ -325,9 +327,9 @@ def test_overlay_then_alert_clears_stale_overlay_shape(): assert client.draw.call_args.kwargs["priority"] == PRIORITY_OVERLAY def test_alert_then_overlay_clears_stale_alert_shape(): - # Symmetric direction: an alert draws first (shape {bg,ci}); once it + # Symmetric direction: an alert draws first (shape {bg,ci_header,ci_rule,ci}); once it # resolves and a run is active, the running badge's shape ({bg,title, - # track,track_fill,eta}) differs and must clear the alert's stale + # track,track_fill,track_tip,eta}) differs and must clear the alert's stale # elements first. The second poll must land at or beyond a full dwell # (Request B: the failure frame is dwell-gated like every other overlay # frame now, unlike the old unconditionally-drawing alert-tier path). @@ -349,11 +351,8 @@ def test_alert_then_overlay_clears_stale_alert_shape(): client.clear.assert_called_once_with("ci_status") def test_quiet_green_then_overlay_clears_stale_green_shape(): - # Quiet "CI ok" text (shape {ci}, no bg) draws first when show_green - # is on and nothing is running; once a run starts, the badge's shape - # differs (it has a bg + several more ids) and must clear first, or - # the old green text -- drawn with a ~1.5x-poll timeout, e.g. 180s at - # the default -- would linger behind/around the badge for minutes. + # The green card draws first; once a run starts, the badge's shape + # differs and must clear before drawing its own full frame. # Green now draws at the overlay tier too, so the second poll must # land at or beyond a full dwell for its draw to even be attempted. client = Mock(); client.draw.return_value = DrawResult.DRAWN @@ -364,7 +363,7 @@ def test_quiet_green_then_overlay_clears_stale_green_shape(): summary = run_once(client, poller, CFG_GREEN, NOW, {}, dry_run=False, running_cache={}, overlay_state=overlay_state) client.clear.assert_not_called() - assert overlay_state["last_shape"] == frozenset({"ci"}) + assert overlay_state["last_shape"] == frozenset({"bg", "ci_header", "ci_rule", "ci"}) assert client.draw.call_args.kwargs["priority"] == PRIORITY_OVERLAY poller.fetch_running_runs.return_value = [_running_run()] @@ -402,7 +401,7 @@ def test_rotation_cycles_ci_badge_then_quota_frames(): if "eta" in by_id: seen.append("ci_badge") else: - seen.append("quota_gql" if by_id["title"]["text"] == "GITHUB GRAPHQL" else "quota_rest") + seen.append("quota_gql" if by_id["title"]["text"] == "GQL LEFT" else "quota_rest") t += timedelta(seconds=2 * OVERLAY_DWELL_SECONDS + 1) assert seen == ["ci_badge", "quota_gql", "quota_rest"] @@ -711,11 +710,11 @@ def test_overlay_dwell_rejected_during_calendar_elevation_resumes_after(): running_cache={}, overlay_state=overlay_state) assert "drawn" in summary2 assert overlay_state.get("last_dwell_end") is not None - assert overlay_state["last_shape"] == frozenset({"bg", "title", "track", "track_fill", "eta"}) + assert overlay_state["last_shape"] == frozenset({"bg", "title", "track", "track_fill", "track_tip", "eta"}) assert client.draw.call_count == 2 # both attempts drew (1st rejected, 2nd landed) -- no crash anywhere -def test_modern_ci_preserves_text_and_icon_when_failure_background_is_removed(): +def test_modern_ci_draws_complete_green_card_after_failure(): client = Mock() client.supports_display_v2 = True client.remove_elements.return_value = True @@ -730,8 +729,10 @@ def test_modern_ci_preserves_text_and_icon_when_failure_background_is_removed(): assert state["last_priority"] == PRIORITY_OVERLAY poller.fetch_runs.return_value = [_run("success")] run_once(client, poller, cfg, NOW + timedelta(seconds=21), {}, False, overlay_state=state) - client.remove_elements.assert_called_once_with("ci_status", ["bg"]) + client.remove_elements.assert_not_called() client.clear.assert_not_called() frame = client.draw.call_args.args[1] - assert {e["id"] for e in frame} == {"ci", "ci_status_icon"} + assert {e["id"] for e in frame} == {"bg", "ci_header", "ci_rule", "ci", "ci_status_icon"} + by_id = {e["id"]: e for e in frame} + assert by_id["ci"]["text"] == "ALL CLEAR" assert all(e["timeout"] == 10 for e in frame) diff --git a/tests/test_nyan_asset.py b/tests/test_nyan_asset.py index 241f627..5d9d3ff 100644 --- a/tests/test_nyan_asset.py +++ b/tests/test_nyan_asset.py @@ -1,6 +1,7 @@ import json from pathlib import Path from tools.anim_encoder import parse_header +from tools.build_nyan_anim import FRAMES, H, W, render_frame, render_frames ASSET = Path(__file__).resolve().parents[1] / "assets" / "nyan" / "nyan_72x16.anim" META = Path(__file__).resolve().parents[1] / "assets" / "nyan" / "meta.json" @@ -10,5 +11,25 @@ def test_committed_asset_parses(): assert h["magic"] == b"bicycle0" assert (h["width"], h["height"]) == (72, 16) assert h["color_mode"] == 0 - assert h["n_display"] >= 1 + assert h["n_display"] == FRAMES assert h["fps"] == json.loads(META.read_text())["fps"] + + +def test_renderer_is_periodic_and_pixel_bounded(): + frames = render_frames() + assert len(frames) == FRAMES == 24 + assert all(len(frame) == W * H for frame in frames) + assert all(0 <= channel <= 255 for frame in frames for pixel in frame for channel in pixel) + assert frames == render_frames() + assert all(render_frame(t) == render_frame(t + FRAMES) for t in range(FRAMES)) + + +def test_generated_frames_have_panel_dimensions(): + from PIL import Image + + frame_paths = sorted((ASSET.parent / "frames").glob("frame_*.png"), + key=lambda path: int(path.stem.split("_")[1])) + assert len(frame_paths) == FRAMES + for path in frame_paths: + with Image.open(path) as image: + assert image.size == (W, H) diff --git a/tests/test_presentation.py b/tests/test_presentation.py index 94d4f92..3d5345b 100644 --- a/tests/test_presentation.py +++ b/tests/test_presentation.py @@ -134,13 +134,22 @@ def test_capability_needs_positive_evidence(): @pytest.mark.parametrize("kind", ["fail", "stuck", "green"]) def test_bitmap_has_transparent_margin_and_does_not_obscure_fallback_text(kind): - original = [text_element("ci")] + original = [ + {**text_element("ci_header"), "x": 2, "y": -2, "width": 68}, + {**text_element("ci"), "x": 2, "y": 6, "width": 68}, + ] frame = ci_bitmap_accent(original, kind, 10) icon = frame[-1] lines = icon["data"].splitlines() assert lines[:3] == ["! XPM2", "7 7 2 1", ". c None"] assert len(lines[4:]) == 7 and all(len(row) == 7 for row in lines[4:]) - assert icon["x"] + 7 <= frame[0]["x"] - assert frame[0]["x"] + frame[0]["width"] == 72 + assert icon["x"] == 2 and icon["y"] == 0 + assert frame[0]["x"] == 12 and frame[0]["width"] == 58 + assert frame[1]["x"] == 2 and frame[1]["width"] == 68 + color = {"fail": "#FFFFFF", "stuck": "#FFCB6B", "green": "#6FFFCF"}[kind] + assert f"X c {color}" in lines assert icon["timeout"] == 10 - assert original == [text_element("ci")] + assert original == [ + {**text_element("ci_header"), "x": 2, "y": -2, "width": 68}, + {**text_element("ci"), "x": 2, "y": 6, "width": 68}, + ] diff --git a/tools/build_nyan_anim.py b/tools/build_nyan_anim.py index 909c10c..588898f 100644 --- a/tools/build_nyan_anim.py +++ b/tools/build_nyan_anim.py @@ -1,7 +1,7 @@ """Render a fixed Nyan Cat loop and compile it to a bicycle0 .anim. Renderer geometry adapted from the community reference app -(maxswinkels/busybar-apps, apps/nyan-cat). Deterministic (seeded RNG) so the +(maxswinkels/busybar-apps, apps/nyan-cat). Deterministic and analytical so the committed asset is reproducible. Build-time only. uv run python -m tools.build_nyan_anim @@ -9,7 +9,6 @@ from __future__ import annotations import json -import random from pathlib import Path from PIL import Image @@ -20,36 +19,55 @@ FRAMES = 24 # ~2s loop at 12 fps FPS = 12 +BACKGROUND=(0x04,0x06,0x14) CRUST=(0xFF,0xCC,0x99); FROSTING=(0xFF,0x99,0xFF); SPRINKLE=(0xDD,0x33,0x88) GRAY=(0x99,0x99,0x99); BLACK=(0,0,0); CHEEK=(0xFF,0x99,0x99); STAR=(0xFF,0xFF,0xFF) RAINBOW=[(0xFF,0,0),(0xFF,0x99,0),(0xFF,0xFF,0),(0x33,0xFF,0),(0,0x99,0xFF),(0x66,0x33,0xFF)] CX,BY=44,3; HX,HY=CX+9,5; TRAIL_END=CX-5 -def _blank(): return [(0,0,0)]*(W*H) +def _blank(): return [BACKGROUND]*(W*H) def _rect(buf,x,y,w,h,rgb): x2,y2=min(W,x+w),min(H,y+h); x,y=max(0,x),max(0,y) for yy in range(y,y2): base=yy*W for xx in range(x,x2): buf[base+xx]=rgb -def _stars_state(): return [{"x":8,"y":3,"p":0},{"x":26,"y":13,"p":2},{"x":46,"y":1,"p":1},{"x":66,"y":11,"p":3}] -def _tick_stars(buf,stars,rng): +def _stars_state(): + return [{"x":8,"y":3,"offset":0},{"x":26,"y":13,"offset":2}, + {"x":46,"y":1,"offset":1},{"x":66,"y":11,"offset":3}] + +def _plot(buf,x,y,rgb): + """Plot a star pixel with horizontal wrapping at the panel edge.""" + if 0 <= y < H: + buf[y*W + (x % W)] = rgb + +def _star_pixels(x,y,phase): + if phase == 0: + return ((x,y),) + if phase == 1: + return ((x,y),(x-1,y),(x+1,y),(x,y-1),(x,y+1)) + if phase == 2: + return ((x,y),(x-2,y),(x-1,y),(x+1,y),(x+2,y), + (x,y-2),(x,y-1),(x,y+1),(x,y+2)) + return ((x-2,y),(x+2,y),(x,y-2),(x,y+2)) + +def _tick_stars(buf,stars,t): for s in stars: - s["x"]-=3; s["p"]=(s["p"]+1)%4 - if s["x"]<-2: s["x"]=W+rng.randint(0,10); s["y"]=rng.randint(1,H-2) - x,y,p=s["x"],s["y"],s["p"] - if p==0: _rect(buf,x,y,1,1,STAR) - elif p==1: _rect(buf,x-1,y,3,1,STAR); _rect(buf,x,y-1,1,3,STAR) - elif p==2: _rect(buf,x-2,y,5,1,STAR); _rect(buf,x,y-2,1,5,STAR) - else: - for dx,dy in ((-2,0),(2,0),(0,-2),(0,2)): _rect(buf,x+dx,y+dy,1,1,STAR) + x=(s["x"] - 3*t) % W + phase=(t + s["offset"]) % 4 + for px,py in _star_pixels(x,s["y"],phase): + _plot(buf,px,py,STAR) def _rainbow(buf,phase): for band,color in enumerate(RAINBOW): y=2+band*2; x=0 while x list[tuple[int, int, int]]: + """Render frame ``t``; all motion is periodic analytical state.""" + phase=(t//3)%2 + buf=_blank() + _tick_stars(buf,_stars_state(),t) + _rainbow(buf,phase) + _cat(buf,phase) + return buf + def render_frames(): - rng=random.Random(1) # deterministic - stars=_stars_state(); frames=[] - for t in range(FRAMES): - phase=(t//3)%2; buf=_blank() - _tick_stars(buf,stars,rng); _rainbow(buf,phase); _cat(buf,phase) - frames.append(buf) - return frames + return [render_frame(t) for t in range(FRAMES)] def main(): out_dir=Path(__file__).resolve().parents[1]/"assets"/"nyan"