feat(ui): pulse tray icon while downloads are active (task 18)#112
feat(ui): pulse tray icon while downloads are active (task 18)#112
Conversation
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
📝 WalkthroughWalkthroughAdds 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
Sequence DiagramsequenceDiagram
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)
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
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.
Greptile SummaryThis 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 Confidence Score: 4/5Safe 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.
|
| 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()
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
- 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.
There was a problem hiding this comment.
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::Intervalacross idle and active periods, polling it only via theif core.is_animating()guard on line 139. During idle periods, the interval continues tracking time internally. When animation resumes, Tokio's defaultBurstbehavior causestick()to return immediately multiple times, fast-forwarding frames instead of waiting one interval duration.To fix:
- Call
tick.reset()whenAnimatorAction::StartAnimationis handled (line 130)- Call
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip)after creating the interval (line 122) to prevent catch-up behaviorPossible 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
📒 Files selected for processing (2)
src-tauri/src/adapters/driven/tray/animator.rssrc-tauri/src/lib.rs
There was a problem hiding this comment.
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.
- 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.
There was a problem hiding this comment.
🧹 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; ifframe_countis 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
📒 Files selected for processing (1)
src-tauri/src/adapters/driven/tray/animator.rs
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 countVerification
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
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
tauri2. Implements task 18 (PRD P0.18).New Features
ActivityTrackerandAnimatorCorewith anIconSwappertrait for testability.EventBus;tokio::select!interval is gated when idle.TauriIconSwapperdrives thetauriTrayIcon;setup_system_trayreturns aTrayIconand the animator is spawned fromlib.rs.Bug Fixes
mpsc::unbounded_channelso lifecycle events aren’t dropped and activity state stays correct; high-frequency progress events are filtered out at the source.MissedTickBehavior::Delayto avoid burst catch-up after idle and keep a steady frame cadence.Written for commit 5786020. Summary will update on new commits.