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}