Skip to main content

sonos_cli/tui/
hooks.rs

1//! TUI hooks system — co-located widget state, SDK subscriptions, and animation.
2//!
3//! Modeled after React hooks but adapted for Rust's ownership model and
4//! ratatui's immediate-mode rendering. Three primitives:
5//!
6//! - `use_state<V>(key, default)` — Persistent local state across renders
7//! - `use_watch(property_handle)` — Subscribe to SDK property, return current value
8//! - `use_animation(key, active)` — Request periodic re-renders when active
9//!
10//! ## Calling Convention
11//!
12//! `use_state` returns `&mut V` which borrows `&mut self` on `Hooks`.
13//! To avoid borrow conflicts, call hooks in this order:
14//!
15//! 1. `use_watch` — returns owned `Option<V>`, borrow released immediately
16//! 2. `use_animation` — `&mut self` borrow released immediately
17//! 3. `use_state` — must be called last or in a scoped block
18//!
19//! ## Frame Lifecycle
20//!
21//! ```text
22//! hooks.begin_frame()     // reset access tracking
23//! terminal.draw(...)      // widgets call hooks
24//! hooks.end_frame()       // evict unaccessed state + handles
25//! ```
26
27use std::any::{Any, TypeId};
28use std::collections::{HashMap, HashSet};
29
30use sonos_sdk::property::{GroupPropertyHandle, PropertyHandle};
31use sonos_state::property::SonosProperty;
32
33use crate::tui::app::App;
34
35// ============================================================================
36// RenderContext
37// ============================================================================
38
39/// Render context passed to all render functions.
40///
41/// Wraps `&App` (read) and `&mut Hooks` (write), satisfying the borrow checker
42/// by separating immutable app data from mutable hook state.
43pub struct RenderContext<'a> {
44    pub app: &'a App,
45    pub hooks: &'a mut Hooks,
46}
47
48// ============================================================================
49// HookKey — state identity
50// ============================================================================
51
52/// Composite key for `use_state`: combines value type with a string name.
53///
54/// Two hooks with the same string key but different value types get separate
55/// storage slots (keyed by `TypeId`).
56#[derive(Hash, Eq, PartialEq, Clone, Debug)]
57struct HookKey {
58    type_id: TypeId,
59    name: String,
60}
61
62impl HookKey {
63    fn new<V: 'static>(name: &str) -> Self {
64        Self {
65            type_id: TypeId::of::<V>(),
66            name: name.to_string(),
67        }
68    }
69}
70
71// ============================================================================
72// ProgressState — moved from app.rs to co-locate with hooks
73// ============================================================================
74
75/// Client-side progress interpolation state for a single group.
76///
77/// Stores a snapshot of position + wall-clock timestamp. While playing,
78/// `interpolated_position_ms()` advances the position based on elapsed time,
79/// capped at 10s to limit drift from system sleep/stalls.
80#[derive(Clone, Debug)]
81pub struct ProgressState {
82    pub last_position_ms: u64,
83    pub last_duration_ms: u64,
84    pub wall_clock_at_last_update: std::time::Instant,
85    pub is_playing: bool,
86}
87
88impl Default for ProgressState {
89    fn default() -> Self {
90        Self {
91            last_position_ms: 0,
92            last_duration_ms: 0,
93            wall_clock_at_last_update: std::time::Instant::now(),
94            is_playing: false,
95        }
96    }
97}
98
99impl ProgressState {
100    /// Update from SDK position and playback data.
101    pub fn update(&mut self, position_ms: u64, duration_ms: u64, is_playing: bool) {
102        // Freeze at interpolated position on pause transition
103        if self.is_playing && !is_playing {
104            self.last_position_ms = self.interpolated_position_ms();
105        }
106
107        self.last_position_ms = position_ms;
108        self.last_duration_ms = duration_ms;
109        self.is_playing = is_playing;
110        self.wall_clock_at_last_update = std::time::Instant::now();
111    }
112
113    /// Compute interpolated position in milliseconds.
114    ///
115    /// Caps interpolation at 10s ahead to limit drift from system sleep/stalls.
116    pub fn interpolated_position_ms(&self) -> u64 {
117        if !self.is_playing {
118            return self.last_position_ms;
119        }
120        let elapsed = self.wall_clock_at_last_update.elapsed().as_millis() as u64;
121        let capped_elapsed = elapsed.min(10_000);
122        (self.last_position_ms + capped_elapsed).min(self.last_duration_ms)
123    }
124}
125
126// ============================================================================
127// Hooks
128// ============================================================================
129
130/// General-purpose hooks system for TUI widget state management.
131///
132/// Stores persistent state, SDK watch handles, and animation registrations.
133/// Uses mark-and-sweep to automatically clean up state when widgets stop
134/// rendering (e.g., screen transitions).
135pub struct Hooks {
136    // use_state storage — keyed by (TypeId, name)
137    states: HashMap<HookKey, Box<dyn Any>>,
138
139    // use_watch storage — type-erased WatchHandle<P>, keyed by "speaker_id:property_key"
140    watches: HashMap<String, Box<dyn Any>>,
141
142    // use_animation — keys of active animations
143    animations: HashSet<String>,
144
145    // Mark-and-sweep: keys accessed during current frame
146    accessed_states: HashSet<HookKey>,
147    accessed_watches: HashSet<String>,
148    accessed_animations: HashSet<String>,
149}
150
151impl Default for Hooks {
152    fn default() -> Self {
153        Self::new()
154    }
155}
156
157impl Hooks {
158    pub fn new() -> Self {
159        Self {
160            states: HashMap::new(),
161            watches: HashMap::new(),
162            animations: HashSet::new(),
163            accessed_states: HashSet::new(),
164            accessed_watches: HashSet::new(),
165            accessed_animations: HashSet::new(),
166        }
167    }
168
169    // -----------------------------------------------------------------------
170    // Frame lifecycle
171    // -----------------------------------------------------------------------
172
173    /// Reset access tracking before a render frame.
174    pub fn begin_frame(&mut self) {
175        self.accessed_states.clear();
176        self.accessed_watches.clear();
177        self.accessed_animations.clear();
178    }
179
180    /// Evict unaccessed state and drop unaccessed watch handles.
181    pub fn end_frame(&mut self) {
182        // Evict unaccessed states
183        self.states
184            .retain(|key, _| self.accessed_states.contains(key));
185
186        // Drop unaccessed watch handles (starts grace periods)
187        self.watches
188            .retain(|key, _| self.accessed_watches.contains(key));
189
190        // Remove unaccessed animations
191        self.animations
192            .retain(|key| self.accessed_animations.contains(key));
193    }
194
195    // -----------------------------------------------------------------------
196    // use_state
197    // -----------------------------------------------------------------------
198
199    /// Get or create persistent local state.
200    ///
201    /// On first call for a given key, creates the state using `default()`.
202    /// On subsequent calls, returns the existing state.
203    ///
204    /// **Must be called last** — the returned `&mut V` borrows `&mut self`,
205    /// preventing other hook calls until the reference is dropped.
206    pub fn use_state<V: 'static>(&mut self, key: &str, default: impl FnOnce() -> V) -> &mut V {
207        let hook_key = HookKey::new::<V>(key);
208        self.accessed_states.insert(hook_key.clone());
209
210        self.states
211            .entry(hook_key)
212            .or_insert_with(|| Box::new(default()))
213            .downcast_mut::<V>()
214            .expect("use_state: type mismatch for key (same key used with different types)")
215    }
216
217    // -----------------------------------------------------------------------
218    // use_watch
219    // -----------------------------------------------------------------------
220
221    /// Subscribe to an SDK speaker property, returning the current value.
222    ///
223    /// Each frame, creates a fresh `WatchHandle` via `prop.watch()` to get
224    /// an up-to-date snapshot. The old handle is replaced (dropped → grace
225    /// period starts → new handle re-acquires → grace period cancelled).
226    /// This is the SDK's intended pattern: "Re-watch each frame to refresh
227    /// the snapshot."
228    ///
229    /// Falls back to `prop.get()` if `watch()` fails.
230    pub fn use_watch<P>(&mut self, prop: &PropertyHandle<P>) -> Option<P>
231    where
232        P: SonosProperty + Clone + 'static,
233    {
234        let key = format!("{}:{}", prop.speaker_id(), P::KEY);
235        self.accessed_watches.insert(key.clone());
236
237        // Create a fresh watch handle each frame to get updated values.
238        // WatchHandle is a snapshot — value is set at creation and never updates.
239        // Replacing the old handle drops it (grace period starts), then the new
240        // handle re-acquires the subscription (grace period cancelled).
241        match prop.watch() {
242            Ok(wh) => {
243                let val = wh.value().cloned();
244                self.watches.insert(key, Box::new(wh));
245                val
246            }
247            Err(e) => {
248                tracing::warn!(
249                    "use_watch failed for {}: {e}, falling back to get()",
250                    P::KEY
251                );
252                prop.get()
253            }
254        }
255    }
256
257    /// Subscribe to an SDK group property, returning the current value.
258    ///
259    /// Same as `use_watch` but for group-scoped properties (e.g., group volume).
260    pub fn use_watch_group<P>(&mut self, prop: &GroupPropertyHandle<P>) -> Option<P>
261    where
262        P: SonosProperty + Clone + 'static,
263    {
264        let key = format!("group:{}:{}", prop.group_id(), P::KEY);
265        self.accessed_watches.insert(key.clone());
266
267        match prop.watch() {
268            Ok(wh) => {
269                let val = wh.value().cloned();
270                self.watches.insert(key, Box::new(wh));
271                val
272            }
273            Err(e) => {
274                tracing::warn!(
275                    "use_watch_group failed for {}: {e}, falling back to get()",
276                    P::KEY
277                );
278                prop.get()
279            }
280        }
281    }
282
283    // -----------------------------------------------------------------------
284    // use_animation
285    // -----------------------------------------------------------------------
286
287    /// Register an animation tick request.
288    ///
289    /// When `active` is true, the event loop's global animation timer will
290    /// mark the app as dirty every ~250ms, triggering re-renders for smooth
291    /// progress bar animation.
292    pub fn use_animation(&mut self, key: &str, active: bool) {
293        let key = key.to_string();
294        self.accessed_animations.insert(key.clone());
295        if active {
296            self.animations.insert(key);
297        } else {
298            self.animations.remove(&key);
299        }
300    }
301
302    /// Check if any widget has registered an active animation.
303    ///
304    /// Called by the event loop between frames to decide whether to tick
305    /// the animation timer.
306    pub fn has_active_animations(&self) -> bool {
307        !self.animations.is_empty()
308    }
309}
310
311// ============================================================================
312// Tests
313// ============================================================================
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318
319    #[test]
320    fn use_state_creates_default_on_first_call() {
321        let mut hooks = Hooks::new();
322        hooks.begin_frame();
323
324        let val = hooks.use_state::<u32>("counter", || 42);
325        assert_eq!(*val, 42);
326    }
327
328    #[test]
329    fn use_state_persists_across_frames() {
330        let mut hooks = Hooks::new();
331
332        // Frame 1: create state
333        hooks.begin_frame();
334        *hooks.use_state::<u32>("counter", || 0) = 10;
335        hooks.end_frame();
336
337        // Frame 2: state persists
338        hooks.begin_frame();
339        let val = hooks.use_state::<u32>("counter", || 0);
340        assert_eq!(*val, 10);
341        hooks.end_frame();
342    }
343
344    #[test]
345    fn use_state_evicts_on_unaccessed_frame() {
346        let mut hooks = Hooks::new();
347
348        // Frame 1: create state
349        hooks.begin_frame();
350        *hooks.use_state::<u32>("counter", || 42) = 100;
351        hooks.end_frame();
352
353        // Frame 2: state NOT accessed → evicted
354        hooks.begin_frame();
355        hooks.end_frame();
356
357        // Frame 3: recreated from default
358        hooks.begin_frame();
359        let val = hooks.use_state::<u32>("counter", || 42);
360        assert_eq!(*val, 42); // default, not 100
361    }
362
363    #[test]
364    fn use_state_different_types_same_name_are_separate() {
365        let mut hooks = Hooks::new();
366        hooks.begin_frame();
367
368        *hooks.use_state::<u32>("val", || 1) = 10;
369        // Borrow on hooks released after this line since u32 is Copy
370
371        let s = hooks.use_state::<String>("val", || "hello".to_string());
372        assert_eq!(s, "hello"); // separate slot, not confused with u32
373    }
374
375    #[test]
376    fn use_animation_registers_and_deregisters() {
377        let mut hooks = Hooks::new();
378        hooks.begin_frame();
379
380        assert!(!hooks.has_active_animations());
381
382        hooks.use_animation("progress", true);
383        assert!(hooks.has_active_animations());
384
385        hooks.use_animation("progress", false);
386        assert!(!hooks.has_active_animations());
387    }
388
389    #[test]
390    fn use_animation_evicts_on_unaccessed_frame() {
391        let mut hooks = Hooks::new();
392
393        // Frame 1: register animation
394        hooks.begin_frame();
395        hooks.use_animation("progress", true);
396        hooks.end_frame();
397        assert!(hooks.has_active_animations());
398
399        // Frame 2: animation NOT accessed → evicted
400        hooks.begin_frame();
401        hooks.end_frame();
402        assert!(!hooks.has_active_animations());
403    }
404
405    #[test]
406    fn progress_state_interpolation() {
407        let mut ps = ProgressState {
408            last_position_ms: 1000,
409            last_duration_ms: 5000,
410            is_playing: false,
411            ..Default::default()
412        };
413
414        // Not playing → returns last position
415        assert_eq!(ps.interpolated_position_ms(), 1000);
416
417        // Playing → interpolates forward
418        ps.is_playing = true;
419        ps.wall_clock_at_last_update = std::time::Instant::now();
420        // Position should be >= 1000 (at least the base)
421        assert!(ps.interpolated_position_ms() >= 1000);
422    }
423
424    #[test]
425    fn progress_state_caps_at_duration() {
426        let ps = ProgressState {
427            last_position_ms: 4900,
428            last_duration_ms: 5000,
429            is_playing: true,
430            // Simulate old timestamp (10+ seconds ago)
431            wall_clock_at_last_update: std::time::Instant::now()
432                - std::time::Duration::from_secs(20),
433        };
434
435        // Should cap at duration, not overflow
436        assert_eq!(ps.interpolated_position_ms(), 5000);
437    }
438
439    #[test]
440    fn mark_and_sweep_preserves_accessed_drops_rest() {
441        let mut hooks = Hooks::new();
442
443        // Frame 1: create multiple states
444        hooks.begin_frame();
445        *hooks.use_state::<u32>("keep", || 1) = 10;
446        // Release borrow
447        let _ = hooks.use_state::<u32>("drop_me", || 2);
448        hooks.use_animation("keep_anim", true);
449        hooks.use_animation("drop_anim", true);
450        hooks.end_frame();
451
452        // Frame 2: only access "keep" variants
453        hooks.begin_frame();
454        let val = hooks.use_state::<u32>("keep", || 99);
455        assert_eq!(*val, 10); // persisted
456        hooks.use_animation("keep_anim", true);
457        hooks.end_frame();
458
459        // Frame 3: verify "drop_me" was evicted
460        hooks.begin_frame();
461        let val = hooks.use_state::<u32>("drop_me", || 99);
462        assert_eq!(*val, 99); // recreated from default
463        hooks.end_frame();
464
465        // And "drop_anim" should be gone
466        // (only "keep_anim" should remain)
467    }
468}