Skip to content

feat(ui): pulse tray icon while downloads are active (task 18)#112

Merged
mpiton merged 3 commits intomainfrom
feat/task-18-tray-animated-icon
Apr 26, 2026
Merged

feat(ui): pulse tray icon while downloads are active (task 18)#112
mpiton merged 3 commits intomainfrom
feat/task-18-tray-animated-icon

Conversation

@mpiton
Copy link
Copy Markdown
Owner

@mpiton mpiton commented Apr 26, 2026

Summary

• Animated tray icon pulses orange dot while downloads active
• Procedurally generated RGBA frames (no binary assets, directly testable)
• Domain-pure state machine (ActivityTracker) isolated from Tauri adapter
• Async event loop with select! optimization: zero CPU wake-ups when idle
• 98%+ unit test coverage on domain logic
• Cross-platform via Tauri 2 API (Linux KDE/GNOME, macOS, Windows)

Changes

activity_tracker.rs: Pure state machine tracks active downloads, emits Activated/Deactivated transitions
frames.rs: Procedural frame generator (triangular wave pulse, alpha antialiasing)
animator.rs: Async animator loop, event filtering, frame advancement
tauri_swapper.rs: Adapter implementing IconSwapper trait for Tauri TrayIcon integration
system_tray.rs / lib.rs: Wiring animator to EventBus and frame count

Verification

All tests passing (967 passed). Build clean. Linters clean.
Acceptance: Icon animates on download start → reverts to static on pause → zero idle CPU.

Ref: PRD-v2 §3 P0.18, PRD §7.5

Summary by CodeRabbit

  • New Features
    • System tray shows a pulsing orange dot while downloads are active and reverts to a static icon when none are active.
    • Animation uses a smooth default frame interval (200ms) and reliably restores the static icon when activity stops or the app shuts down.

Summary by cubic

Adds a pulsing tray icon (orange dot) while any download is active, then restores the static icon when none are running. Zero idle CPU, cross‑platform via tauri 2. Implements task 18 (PRD P0.18).

  • New Features

    • Pulse animation runs when ≥1 download is active; stops at zero.
    • Procedural frames (8 × 32×32 RGBA); no PNG assets.
    • Domain-pure ActivityTracker and AnimatorCore with an IconSwapper trait for testability.
    • Async animator on the EventBus; tokio::select! interval is gated when idle.
    • TauriIconSwapper drives the tauri TrayIcon; setup_system_tray returns a TrayIcon and the animator is spawned from lib.rs.
  • Bug Fixes

    • Reverted to a lossless mpsc::unbounded_channel so lifecycle events aren’t dropped and activity state stays correct; high-frequency progress events are filtered out at the source.
    • Set MissedTickBehavior::Delay to avoid burst catch-up after idle and keep a steady frame cadence.
    • Missing default window icon now logs a warning and disables the animator instead of failing setup.

Written for commit 5786020. Summary will update on new commits.

Tray now switches from the static window icon to an orange pulsing dot
whenever ≥1 download is `Downloading`/`Resumed`/`ResumedFromWait`, and
reverts to static once the active set returns to zero.

The animator splits cleanly between a domain-pure state machine
(`ActivityTracker` + `AnimatorCore`) and a thin Tauri-bound icon
swapper, so the loop is fully unit-tested without a Tauri runtime.
The interval arm of the `tokio::select!` is gated by
`if core.is_animating()` so an idle tray costs zero timer wake-ups.

Frames are generated procedurally (8×32×32 RGBA, triangular-wave
pulse) instead of shipping binary PNG assets — easier to tweak and
testable. The adapter uses only the cross-platform Tauri 2
`TrayIcon::set_icon(Option<Image>)` API; no `cfg(target_os)` paths.

Refs: PRD-v2 §3 P0.18, PRD §7.5
@github-actions github-actions Bot added documentation Improvements or additions to documentation rust labels Apr 26, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 26, 2026

📝 Walkthrough

Walkthrough

Adds an animated system tray icon: a pulsing orange dot shown while downloads are active. Introduces an activity tracker, an animator core + async animator task, a 32×32 RGBA pulse frame generator, and a Tauri icon swapper; setup_system_tray now returns the tray handle and animation runs at a 200ms default interval.

Changes

Cohort / File(s) Summary
Documentation
CHANGELOG.md
Documented the animated tray feature, components, and 200ms default frame interval.
Tray Activity Tracking
src-tauri/src/adapters/driven/tray/activity_tracker.rs
Added ActivityTracker and public Transition enum; tracks active DownloadIds and yields Activated/Deactivated/NoChange on domain events; includes unit tests.
Tray Animation Core
src-tauri/src/adapters/driven/tray/animator.rs
Added AnimatorCore, AnimatorAction, DEFAULT_FRAME_INTERVAL, IconSwapper trait, and spawn_tray_animator (async loop subscribing to EventBus with filters, interval-driven ticks, show_frame/show_static semantics); includes unit and tokio tests.
Tray Frame Generation
src-tauri/src/adapters/driven/tray/frames.rs
Added TrayFrame and pulse_frames() producing 8 row-major 32×32 RGBA frames for the pulsing orange dot (triangular-wave radius, alpha edge); includes tests.
Tauri Integration & Tray Setup
src-tauri/src/adapters/driven/tray/tauri_swapper.rs, src-tauri/src/adapters/driven/tray/system_tray.rs
Added TauriIconSwapper owning Image<'static> frames and static icon, implements IconSwapper and logs tracing::warn! on failures; setup_system_tray now returns TrayIcon.
Module Exports & App Init
src-tauri/src/adapters/driven/tray/mod.rs, src-tauri/src/lib.rs
Re-exported animation symbols (DEFAULT_FRAME_INTERVAL, IconSwapper, spawn_tray_animator, pulse_frames, TauriIconSwapper); lib.rs captures returned TrayIcon, builds frames from default window icon when present, constructs TauriIconSwapper, and starts spawn_tray_animator with DEFAULT_FRAME_INTERVAL.

Sequence Diagram

sequenceDiagram
    participant EB as EventBus
    participant AT as ActivityTracker
    participant AC as AnimatorCore
    participant IS as IconSwapper
    participant TI as Tauri TrayIcon

    EB->>AT: emit DomainEvent (download started)
    AT->>AC: Activated
    AC->>AC: reset frame index, start animating
    AC->>IS: show_frame(0)
    IS->>TI: set_icon(frame 0)

    loop every 200ms
        AC->>AC: tick → advance frame
        AC->>IS: show_frame(n)
        IS->>TI: set_icon(frame n)
    end

    EB->>AT: emit DomainEvent (download completed)
    AT->>AC: Deactivated
    AC->>AC: stop animating
    AC->>IS: show_static()
    IS->>TI: set_icon(static)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I stitched eight orange frames tonight,

a tiny pulse to blink in flight.
Events hop in, then hop away—
I twitch my dot, then pause the play.
Small frames, bright blink, I nibble logs light.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: adding an animated tray icon that pulses when downloads are active, directly aligning with the primary feature across all modified files.
Docstring Coverage ✅ Passed Docstring coverage is 96.77% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/task-18-tray-animated-icon

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 8 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src-tauri/src/lib.rs">

<violation number="1" location="src-tauri/src/lib.rs:376">
P1: Avoid propagating an error when the default window icon is missing; this can abort app startup for a non-critical tray animation feature.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread src-tauri/src/lib.rs Outdated
@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Apr 26, 2026

Greptile Summary

This PR adds a pulsing tray icon (orange dot, 8 × 32×32 RGBA frames, triangular-wave pulse) that animates while any download is active and reverts to the static icon when the active-download set returns to zero. The implementation is cleanly layered into a domain-pure ActivityTracker/AnimatorCore, a procedural frame generator, a testable IconSwapper trait, and a Tauri adapter — with zero idle CPU via a gated tokio::select! interval.

Confidence Score: 4/5

Safe to merge; the one new P2 finding is low-risk given pulse_frames() always returns 8 frames, and the previously flagged unbounded channel is a known outstanding item.

No new P0/P1 issues found. A P2 concern exists in TauriIconSwapper::new silently dropping the TrayIcon (and thus removing the tray from the system) when frames is empty, with no warning log at the call site. The unbounded channel noted in a prior review thread remains unaddressed per the diff but is pre-existing context. The domain logic, frame generation, and async wiring are all solid and well-tested.

src-tauri/src/adapters/driven/tray/tauri_swapper.rs — the None branch drops TrayIcon silently.

Important Files Changed

Filename Overview
src-tauri/src/adapters/driven/tray/activity_tracker.rs New pure state machine: tracks active downloads via HashSet, emits Activated/Deactivated/NoChange transitions. Well-designed and comprehensively tested.
src-tauri/src/adapters/driven/tray/animator.rs New async animator: AnimatorCore pure state machine is clean; spawn_tray_animator uses mpsc::unbounded_channel for the EventBus subscriber (noted in previous review). tokio::select! with if-guard correctly idles the tick arm when not animating.
src-tauri/src/adapters/driven/tray/frames.rs Procedural RGBA frame generator: triangular-wave radius pulse produces a smooth looping animation with consistent inter-frame deltas. Unit tests cover shape, colors, and corner transparency.
src-tauri/src/adapters/driven/tray/tauri_swapper.rs Tauri adapter: correctly guards against empty frames, uses set_icon with warn! on failure. Silent TrayIcon drop when frames is empty could remove the tray icon with no warning (P2).
src-tauri/src/adapters/driven/tray/system_tray.rs Minor change: returns TrayIcon handle instead of () so lib.rs can build the swapper. Clean, no issues.
src-tauri/src/lib.rs Wiring code: graceful fallback for missing default icon (logs warn, skips animator). Previously flagged ? propagation is now resolved. Animator setup is clean but the None branch from TauriIconSwapper::new has no warning.

Sequence Diagram

sequenceDiagram
    participant EB as EventBus
    participant Sub as subscriber closure
    participant CH as mpsc channel
    participant Task as async task (tokio::spawn)
    participant Core as AnimatorCore
    participant Swapper as TauriIconSwapper
    participant Tray as TrayIcon (Tauri)

    EB->>Sub: publish(DownloadStarted)
    Sub->>Sub: is_relevant? yes
    Sub->>CH: tx.send(event)
    CH->>Task: rx.recv()
    Task->>Core: handle_event(event)
    Core-->>Task: StartAnimation
    Task->>Swapper: show_frame(0)
    Swapper->>Tray: set_icon(frames[0])

    loop every 200ms (if animating)
        Task->>Core: tick()
        Core-->>Task: Some(frame_idx)
        Task->>Swapper: show_frame(frame_idx)
        Swapper->>Tray: set_icon(frames[frame_idx])
    end

    EB->>Sub: publish(DownloadPaused)
    Sub->>CH: tx.send(event)
    CH->>Task: rx.recv()
    Task->>Core: handle_event(event)
    Core-->>Task: StopAnimation
    Task->>Swapper: show_static()
    Swapper->>Tray: set_icon(static_icon)

    Note over Task: channel closed (shutdown)
    Task->>Swapper: show_static()
Loading

Fix All in Claude Code

Prompt To Fix All With AI
This is a comment left during a code review.
Path: src-tauri/src/adapters/driven/tray/tauri_swapper.rs
Line: 29-32

Comment:
**Silent `TrayIcon` drop removes tray from system**

When `frames.is_empty()`, `tray` is consumed by this function and dropped when `None` is returned. In Tauri 2, `TrayIcon` is RAII-managed — dropping the handle deregisters the tray icon from the system. The call-site in `lib.rs` has no warning branch for the `None` case (only the missing-`static_icon` case is logged), so if `pulse_frames()` ever returned empty the tray icon would silently vanish with no log entry.

Consider returning `tray` to the caller (e.g., via `Result<Self, TrayIcon>`) or at minimum logging before consuming it:

```rust
if frames.is_empty() {
    tracing::warn!("no animation frames supplied; tray animator disabled");
    return None;
}
```

How can I resolve this? If you propose a fix, please make it concise.

Reviews (3): Last reviewed commit: "fix(ui): revert tray animator to lossles..." | Re-trigger Greptile

Comment thread src-tauri/src/lib.rs Outdated
Comment thread src-tauri/src/adapters/driven/tray/animator.rs
- lib.rs: missing default window icon now logs a warning and skips the
  animator instead of aborting Tauri setup. Matches the logging-only
  fallback used when system tray init fails.
- animator.rs: replace unbounded mpsc channel with a 64-slot bounded
  channel + try_send. ActivityTracker is idempotent, so dropping events
  under burst load is safe and the memory ceiling is now explicit.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
src-tauri/src/adapters/driven/tray/animator.rs (1)

120-145: Reset the interval timer when starting animation to prevent frame skip-ahead.

The loop reuses one tokio::time::Interval across idle and active periods, polling it only via the if core.is_animating() guard on line 139. During idle periods, the interval continues tracking time internally. When animation resumes, Tokio's default Burst behavior causes tick() to return immediately multiple times, fast-forwarding frames instead of waiting one interval duration.

To fix:

  • Call tick.reset() when AnimatorAction::StartAnimation is handled (line 130)
  • Call tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip) after creating the interval (line 122) to prevent catch-up behavior
Possible fix
     tokio::spawn(async move {
         let mut core = AnimatorCore::new(frame_count);
         let mut tick = interval(frame_interval);
+        tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
         // Skip the immediate first tick so we don't redraw the static icon.
         tick.tick().await;
         loop {
             tokio::select! {
                 maybe_event = rx.recv() => {
@@
                     match core.handle_event(&event) {
                         AnimatorAction::StartAnimation => {
+                            tick.reset();
                             swapper.show_frame(core.current_frame());
                         }
                         AnimatorAction::StopAnimation => {
                             swapper.show_static();
                         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src-tauri/src/adapters/driven/tray/animator.rs` around lines 120 - 145, The
interval is reused across idle/active periods causing burst catch-up; after
creating the interval used by AnimatorCore (the variable tick) call
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip), and when
handling AnimatorAction::StartAnimation in the match for
core.handle_event(&event) call tick.reset() so the timer is restarted when
animation begins; update the code around AnimatorCore, tick, and the
AnimatorAction::StartAnimation branch to perform these calls.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src-tauri/src/adapters/driven/tray/animator.rs`:
- Around line 109-117: The channel send in the event_bus.subscribe closure
currently uses tx.try_send and drops events when full, which can lose lifecycle
deltas important for ActivityTracker; change this to guarantee delivery by
either (a) switching to an unbounded channel or (b) use an async/await-capable
send (await tx.send(...)) so sends are lossless, or alternatively emit a
coalesced active/idle signal instead of raw DomainEvent to ensure idempotent
delivery; update the subscription closure that captures tx, the DomainEvent type
being sent, and the receiving logic around rx to match the chosen lossless
approach (e.g., replace try_send(event.clone()) with a guaranteed send and
handle backpressure on rx accordingly).

---

Nitpick comments:
In `@src-tauri/src/adapters/driven/tray/animator.rs`:
- Around line 120-145: The interval is reused across idle/active periods causing
burst catch-up; after creating the interval used by AnimatorCore (the variable
tick) call tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip),
and when handling AnimatorAction::StartAnimation in the match for
core.handle_event(&event) call tick.reset() so the timer is restarted when
animation begins; update the code around AnimatorCore, tick, and the
AnimatorAction::StartAnimation branch to perform these calls.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 584ae53a-0fbc-42c3-a10e-c222e0ac1330

📥 Commits

Reviewing files that changed from the base of the PR and between fbcff85 and 97bb03a.

📒 Files selected for processing (2)
  • src-tauri/src/adapters/driven/tray/animator.rs
  • src-tauri/src/lib.rs

Comment thread src-tauri/src/adapters/driven/tray/animator.rs Outdated
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 2 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src-tauri/src/adapters/driven/tray/animator.rs">

<violation number="1" location="src-tauri/src/adapters/driven/tray/animator.rs:111">
P2: Bounded `try_send` drops lifecycle events, which can desynchronize `ActivityTracker` and leave the tray animation in the wrong state.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread src-tauri/src/adapters/driven/tray/animator.rs Outdated
- animator.rs: revert to mpsc::unbounded_channel. ActivityTracker tracks
  per-download add/remove transitions, so dropping a Started(B) followed
  by a delivered Paused(A) would leave the tracker thinking we are idle
  while B is still downloading. The channel only carries already-filtered
  lifecycle events and is drained immediately, so unbounded growth is
  not a practical concern.
- animator.rs: set MissedTickBehavior::Delay on the frame interval. With
  the default Burst behavior, ticks accumulated while is_animating() was
  false would all fire as soon as animation restarts, causing frame
  skip-ahead. Delay reschedules the next tick relative to when the loop
  observes it, so frames advance at a clean periodic cadence.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src-tauri/src/adapters/driven/tray/animator.rs (1)

53-55: Prefer a fallible constructor over a public panic path.

Line 54 uses assert! in a public constructor; if frame_count is ever miswired, this crashes the task/process path instead of failing gracefully. Consider making construction fallible and handling it at Line 125.

♻️ Proposed refactor
-    pub fn new(frame_count: usize) -> Self {
-        assert!(frame_count > 0, "frame_count must be ≥ 1");
+    pub fn try_new(frame_count: usize) -> Option<Self> {
+        if frame_count == 0 {
+            return None;
+        }
         Self {
             tracker: ActivityTracker::new(),
             frame_count,
             frame_index: 0,
             animating: false,
         }
     }
-        let mut core = AnimatorCore::new(frame_count);
+        let Some(mut core) = AnimatorCore::try_new(frame_count) else {
+            return;
+        };

Also applies to: 125-125

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src-tauri/src/adapters/driven/tray/animator.rs` around lines 53 - 55, The
public constructor pub fn new(frame_count: usize) -> Self in animator.rs should
not panic on invalid input; change it to a fallible constructor (e.g., pub fn
new(frame_count: usize) -> Result<Self, SomeError> or -> Option<Self>) that
returns an error/None when frame_count == 0 instead of using assert!, and update
the call site (the code invoking Animator::new around the previous Line 125) to
handle the Result/Option (propagate the error or handle it gracefully) so
construction fails without terminating the process; keep the rest of the struct
initialization identical and pick a suitable error type or create a small enum
(e.g., AnimatorError::InvalidFrameCount) to represent the failure.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src-tauri/src/adapters/driven/tray/animator.rs`:
- Around line 53-55: The public constructor pub fn new(frame_count: usize) ->
Self in animator.rs should not panic on invalid input; change it to a fallible
constructor (e.g., pub fn new(frame_count: usize) -> Result<Self, SomeError> or
-> Option<Self>) that returns an error/None when frame_count == 0 instead of
using assert!, and update the call site (the code invoking Animator::new around
the previous Line 125) to handle the Result/Option (propagate the error or
handle it gracefully) so construction fails without terminating the process;
keep the rest of the struct initialization identical and pick a suitable error
type or create a small enum (e.g., AnimatorError::InvalidFrameCount) to
represent the failure.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: cc7c4ca0-d30b-4257-b20a-d45e60ac595b

📥 Commits

Reviewing files that changed from the base of the PR and between 97bb03a and 5786020.

📒 Files selected for processing (1)
  • src-tauri/src/adapters/driven/tray/animator.rs

@mpiton mpiton merged commit 33e0050 into main Apr 26, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation rust

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant