Skip to main content

clawless_tui/projection/
mod.rs

1//! Pull-based, queryable view of the event stream
2//!
3//! This module defines [`Projection`] and [`Entry`], the pull-based counterpart to the push-based
4//! [`TerminalPresenter`]. A projection consumes events from an [`EventReceiver`] in the background
5//! and provides read access to accumulated state at any time. TUI applications query the projection
6//! on each render frame without interacting with the event system directly.
7//!
8//! The projection translates internal [`Event`]s into [`Entry`] values so that TUI consumers never
9//! interact with the event system directly. Queries return cloned snapshots of the accumulated
10//! state. The read lock is held only while cloning the snapshot, keeping contention with the
11//! drain task's write lock brief.
12//!
13//! # Examples
14//!
15//! ```
16//! use clawless_core::event::{Event, event_channel};
17//! use clawless_tui::projection::Projection;
18//!
19//! # #[tokio::main]
20//! # async fn main() {
21//! let (sender, receiver) = event_channel();
22//! let projection = Projection::new(receiver);
23//!
24//! sender.send(Event::Message("hello".to_string()))
25//!     .await
26//!     .expect("should send");
27//! drop(sender);
28//!
29//! // Wait for the drain task, which finishes once the dropped sender closes the channel
30//! while !projection.is_complete() {
31//!     tokio::task::yield_now().await;
32//! }
33//!
34//! assert_eq!(projection.entries().len(), 1);
35//! # }
36//! ```
37//!
38//! [`Event`]: clawless_core::event::Event
39//! [`EventReceiver`]: clawless_core::event::EventReceiver
40//! [`TerminalPresenter`]: https://docs.rs/clawless-cli/latest/clawless_cli/presenter/struct.TerminalPresenter.html
41
42use std::sync::{Arc, RwLock};
43
44use clawless_core::event::{Event, EventReceiver};
45use tokio::task::JoinHandle;
46
47pub use self::entry::Entry;
48use self::state::ProjectionState;
49
50/// The form of an [`Event`] that a TUI application reads
51mod entry;
52/// The state that the projection lock protects
53mod state;
54
55/// Pull-based, queryable view of the event stream
56///
57/// `Projection` consumes events from an [`EventReceiver`] in the background and provides read
58/// access to accumulated state at any time. TUI applications query the projection on each render
59/// frame without interacting with the event system directly.
60///
61/// Construction starts a background drain task via [`tokio::spawn`]. The task reads events from
62/// the receiver, translates each into an [`Entry`], and appends it to internal storage. When the
63/// event channel closes (all senders dropped), the task marks the projection as complete.
64///
65/// Query methods take `&self` and return cloned snapshots of the accumulated state. The drain
66/// task and query callers synchronize through a [`RwLock`], allowing concurrent reads from
67/// multiple render frames without blocking on each other.
68///
69/// # Examples
70///
71/// ```
72/// use clawless_core::event::{Event, event_channel};
73/// use clawless_tui::projection::Projection;
74///
75/// # #[tokio::main]
76/// # async fn main() {
77/// let (sender, receiver) = event_channel();
78/// let projection = Projection::new(receiver);
79///
80/// sender.send(Event::Message("processing".to_string()))
81///     .await
82///     .expect("should send");
83/// drop(sender);
84///
85/// // Wait for the drain task, which finishes once the dropped sender closes the channel
86/// while !projection.is_complete() {
87///     tokio::task::yield_now().await;
88/// }
89///
90/// let messages = projection.messages();
91/// assert_eq!(messages.len(), 1);
92/// # }
93/// ```
94///
95/// [`EventReceiver`]: clawless_core::event::EventReceiver
96/// [`RwLock`]: std::sync::RwLock
97// r[impl projection.new]
98// r[impl projection.new.drain]
99// r[impl projection.safety.send]
100// r[impl projection.safety.sync]
101// r[impl projection.safety.unpin]
102#[derive(Debug)]
103pub struct Projection {
104    /// The entries so far, which the projection shares with the drain task
105    state: Arc<RwLock<ProjectionState>>,
106    /// Handle to the drain task, which stops when Clawless drops the projection
107    _drain_handle: JoinHandle<()>,
108}
109
110impl Projection {
111    /// Creates a new projection that drains events from the given receiver
112    ///
113    /// Spawns a background task that reads events from `receiver`, translates each into an
114    /// [`Entry`], and appends it to internal storage. The task runs until the event channel
115    /// closes, at which point it marks the projection as complete.
116    ///
117    /// The returned projection is immediately ready to query. Early queries return empty results
118    /// until events arrive.
119    ///
120    /// # Panics
121    ///
122    /// Panics if called outside of a Tokio runtime.
123    ///
124    /// # Examples
125    ///
126    /// ```
127    /// use clawless_core::event::event_channel;
128    /// use clawless_tui::projection::Projection;
129    ///
130    /// # #[tokio::main]
131    /// # async fn main() {
132    /// let (_sender, receiver) = event_channel();
133    /// let projection = Projection::new(receiver);
134    ///
135    /// assert!(projection.entries().is_empty());
136    /// # }
137    /// ```
138    pub fn new(receiver: EventReceiver) -> Self {
139        let state = Arc::new(RwLock::new(ProjectionState::default()));
140        let drain_state = Arc::clone(&state);
141
142        let handle = tokio::spawn(drain(receiver, drain_state));
143
144        Self {
145            state,
146            _drain_handle: handle,
147        }
148    }
149
150    /// Returns all accumulated entries in receive order
151    ///
152    /// Returns a cloned snapshot of the entry list. The snapshot is consistent: it reflects all
153    /// events drained up to the moment the read lock is acquired.
154    ///
155    /// # Panics
156    ///
157    /// Panics if the internal lock is poisoned.
158    // r[impl projection.query.entries]
159    // Panics only on a poisoned lock, as documented above.
160    #[allow(clippy::expect_used)]
161    pub fn entries(&self) -> Vec<Entry> {
162        self.state.read().expect("lock poisoned").entries()
163    }
164
165    /// Returns accumulated message entries only
166    ///
167    /// Equivalent to calling [`entries`] and filtering to [`Entry::Message`] variants.
168    ///
169    /// # Panics
170    ///
171    /// Panics if the internal lock is poisoned.
172    ///
173    /// [`entries`]: Projection::entries
174    // r[impl projection.query.messages]
175    // Panics only on a poisoned lock, as documented above.
176    #[allow(clippy::expect_used)]
177    pub fn messages(&self) -> Vec<Entry> {
178        self.state.read().expect("lock poisoned").messages()
179    }
180
181    /// Returns accumulated detail entries only
182    ///
183    /// Equivalent to calling [`entries`] and filtering to [`Entry::Detail`] variants.
184    ///
185    /// # Panics
186    ///
187    /// Panics if the internal lock is poisoned.
188    ///
189    /// [`entries`]: Projection::entries
190    // r[impl projection.query.details]
191    // Panics only on a poisoned lock, as documented above.
192    #[allow(clippy::expect_used)]
193    pub fn details(&self) -> Vec<Entry> {
194        self.state.read().expect("lock poisoned").details()
195    }
196
197    /// Returns accumulated artifact entries only
198    ///
199    /// Equivalent to calling [`entries`] and filtering to [`Entry::Artifact`] variants.
200    ///
201    /// # Panics
202    ///
203    /// Panics if the internal lock is poisoned.
204    ///
205    /// [`entries`]: Projection::entries
206    // r[impl projection.query.artifacts]
207    // Panics only on a poisoned lock, as documented above.
208    #[allow(clippy::expect_used)]
209    pub fn artifacts(&self) -> Vec<Entry> {
210        self.state.read().expect("lock poisoned").artifacts()
211    }
212
213    /// Reports whether the event stream has closed and all buffered events have been drained
214    ///
215    /// Returns `true` once all [`EventSender`]s have been dropped and the drain task has
216    /// processed every buffered event. Before that point, returns `false`.
217    ///
218    /// # Panics
219    ///
220    /// Panics if the internal lock is poisoned.
221    ///
222    /// [`EventSender`]: clawless_core::event::EventSender
223    // r[impl projection.lifecycle.complete]
224    // Panics only on a poisoned lock, as documented above.
225    #[allow(clippy::expect_used)]
226    pub fn is_complete(&self) -> bool {
227        self.state.read().expect("lock poisoned").is_complete()
228    }
229}
230
231/// Drains events from the receiver into the shared state
232///
233/// Runs until `receiver.recv()` returns `None` (all senders dropped, channel empty). Each event
234/// is translated into an [`Entry`] and appended to the state. When the loop exits, the state is
235/// marked as complete.
236///
237/// # Panics
238///
239/// Panics if the internal lock is poisoned.
240// The lock is poisoned only if another thread panicked while holding it. The drain task has
241// no way to publish events into a state it cannot lock, so it fails loudly instead.
242#[allow(clippy::expect_used)]
243async fn drain(mut receiver: EventReceiver, state: Arc<RwLock<ProjectionState>>) {
244    while let Some(event) = receiver.recv().await {
245        let entry = match event {
246            Event::Message(text) => Entry::Message(text),
247            Event::Detail(text) => Entry::Detail(text),
248            Event::Artifact(artifact) => Entry::Artifact(Arc::from(artifact)),
249        };
250        state.write().expect("lock poisoned").push(entry);
251    }
252    state.write().expect("lock poisoned").set_complete();
253}
254
255#[cfg(test)]
256mod tests {
257    // An assertion in a test panics by design. A `# Panics` section on every test
258    // would repeat that and give the reader no information.
259    #![allow(clippy::missing_panics_doc)]
260
261    use std::fmt;
262
263    use clawless_core::event::event_channel;
264    use serde::Serialize;
265
266    use super::*;
267
268    #[derive(Clone, Debug, Serialize)]
269    struct TestArtifact {
270        value: String,
271    }
272
273    impl fmt::Display for TestArtifact {
274        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
275            write!(f, "{}", self.value)
276        }
277    }
278
279    // r[verify projection.query.artifacts]
280    #[tokio::test]
281    async fn artifacts_returns_artifact_entries_only() {
282        let (sender, receiver) = event_channel();
283        let projection = Projection::new(receiver);
284
285        sender
286            .send(Event::Message("msg".to_string()))
287            .await
288            .expect("should send");
289        sender
290            .send(Event::Artifact(Box::new(TestArtifact {
291                value: "art".to_string(),
292            })))
293            .await
294            .expect("should send");
295        drop(sender);
296        tokio::task::yield_now().await;
297
298        let artifacts = projection.artifacts();
299
300        assert_eq!(artifacts.len(), 1);
301        let Entry::Artifact(a) = &artifacts[0] else {
302            panic!("expected Entry::Artifact");
303        };
304        assert_eq!(a.to_string(), "art");
305    }
306
307    // r[verify projection.query.details]
308    #[tokio::test]
309    async fn details_returns_detail_entries_only() {
310        let (sender, receiver) = event_channel();
311        let projection = Projection::new(receiver);
312
313        sender
314            .send(Event::Detail("dtl".to_string()))
315            .await
316            .expect("should send");
317        sender
318            .send(Event::Message("msg".to_string()))
319            .await
320            .expect("should send");
321        sender
322            .send(Event::Detail("dtl2".to_string()))
323            .await
324            .expect("should send");
325        drop(sender);
326        tokio::task::yield_now().await;
327
328        let details = projection.details();
329
330        assert_eq!(details.len(), 2);
331        let Entry::Detail(s) = &details[0] else {
332            panic!("expected Entry::Detail");
333        };
334        assert_eq!(s, "dtl");
335        let Entry::Detail(s) = &details[1] else {
336            panic!("expected Entry::Detail");
337        };
338        assert_eq!(s, "dtl2");
339    }
340
341    #[tokio::test]
342    async fn drain_processes_buffered_events_before_completing() {
343        let (sender, receiver) = event_channel();
344
345        sender
346            .send(Event::Message("buffered".to_string()))
347            .await
348            .expect("should send");
349        drop(sender);
350
351        let projection = Projection::new(receiver);
352        tokio::task::yield_now().await;
353
354        assert!(projection.is_complete());
355        let entries = projection.entries();
356        assert_eq!(entries.len(), 1);
357        let Entry::Message(s) = &entries[0] else {
358            panic!("expected Entry::Message");
359        };
360        assert_eq!(s, "buffered");
361    }
362
363    // r[verify projection.entry.artifact]
364    #[tokio::test]
365    async fn entries_returns_artifact_events_as_entries() {
366        let (sender, receiver) = event_channel();
367        let projection = Projection::new(receiver);
368
369        sender
370            .send(Event::Artifact(Box::new(TestArtifact {
371                value: "result".to_string(),
372            })))
373            .await
374            .expect("should send");
375        drop(sender);
376        tokio::task::yield_now().await;
377
378        let entries = projection.entries();
379
380        assert_eq!(entries.len(), 1);
381        let Entry::Artifact(a) = &entries[0] else {
382            panic!("expected Entry::Artifact");
383        };
384        assert_eq!(a.to_string(), "result");
385    }
386
387    // r[verify projection.entry.detail]
388    #[tokio::test]
389    async fn entries_returns_detail_events_as_entries() {
390        let (sender, receiver) = event_channel();
391        let projection = Projection::new(receiver);
392
393        sender
394            .send(Event::Detail("info".to_string()))
395            .await
396            .expect("should send");
397        drop(sender);
398        tokio::task::yield_now().await;
399
400        let entries = projection.entries();
401
402        assert_eq!(entries.len(), 1);
403        let Entry::Detail(s) = &entries[0] else {
404            panic!("expected Entry::Detail");
405        };
406        assert_eq!(s, "info");
407    }
408
409    // r[verify projection.entry.message]
410    // r[verify projection.query.entries]
411    #[tokio::test]
412    async fn entries_returns_message_events_as_entries() {
413        let (sender, receiver) = event_channel();
414        let projection = Projection::new(receiver);
415
416        sender
417            .send(Event::Message("hello".to_string()))
418            .await
419            .expect("should send");
420        drop(sender);
421        tokio::task::yield_now().await;
422
423        let entries = projection.entries();
424
425        assert_eq!(entries.len(), 1);
426        let Entry::Message(s) = &entries[0] else {
427            panic!("expected Entry::Message");
428        };
429        assert_eq!(s, "hello");
430    }
431
432    // r[verify projection.entry.order]
433    #[tokio::test]
434    async fn entries_preserves_receive_order() {
435        let (sender, receiver) = event_channel();
436        let projection = Projection::new(receiver);
437
438        sender
439            .send(Event::Message("first".to_string()))
440            .await
441            .expect("should send");
442        sender
443            .send(Event::Detail("second".to_string()))
444            .await
445            .expect("should send");
446        sender
447            .send(Event::Message("third".to_string()))
448            .await
449            .expect("should send");
450        drop(sender);
451        tokio::task::yield_now().await;
452
453        let entries = projection.entries();
454
455        assert_eq!(entries.len(), 3);
456        let Entry::Message(s) = &entries[0] else {
457            panic!("expected Entry::Message");
458        };
459        assert_eq!(s, "first");
460        let Entry::Detail(s) = &entries[1] else {
461            panic!("expected Entry::Detail");
462        };
463        assert_eq!(s, "second");
464        let Entry::Message(s) = &entries[2] else {
465            panic!("expected Entry::Message");
466        };
467        assert_eq!(s, "third");
468    }
469
470    #[tokio::test]
471    async fn is_complete_returns_false_while_channel_open() {
472        let (_sender, receiver) = event_channel();
473        let projection = Projection::new(receiver);
474
475        tokio::task::yield_now().await;
476
477        assert!(!projection.is_complete());
478    }
479
480    // r[verify projection.query.messages]
481    #[tokio::test]
482    async fn messages_returns_message_entries_only() {
483        let (sender, receiver) = event_channel();
484        let projection = Projection::new(receiver);
485
486        sender
487            .send(Event::Message("msg".to_string()))
488            .await
489            .expect("should send");
490        sender
491            .send(Event::Detail("dtl".to_string()))
492            .await
493            .expect("should send");
494        sender
495            .send(Event::Message("msg2".to_string()))
496            .await
497            .expect("should send");
498        drop(sender);
499        tokio::task::yield_now().await;
500
501        let messages = projection.messages();
502
503        assert_eq!(messages.len(), 2);
504        let Entry::Message(s) = &messages[0] else {
505            panic!("expected Entry::Message");
506        };
507        assert_eq!(s, "msg");
508        let Entry::Message(s) = &messages[1] else {
509            panic!("expected Entry::Message");
510        };
511        assert_eq!(s, "msg2");
512    }
513
514    // r[verify projection.new]
515    #[tokio::test]
516    async fn new_returns_empty_projection() {
517        let (_sender, receiver) = event_channel();
518
519        let projection = Projection::new(receiver);
520
521        assert!(projection.entries().is_empty());
522        assert!(!projection.is_complete());
523    }
524
525    // r[verify projection.new.drain]
526    // r[verify projection.lifecycle.complete]
527    #[tokio::test]
528    async fn new_starts_drain_that_completes_when_channel_closes() {
529        let (sender, receiver) = event_channel();
530        let projection = Projection::new(receiver);
531
532        drop(sender);
533        tokio::task::yield_now().await;
534
535        assert!(projection.is_complete());
536    }
537
538    // r[verify projection.safety.send]
539    #[test]
540    fn trait_send() {
541        fn assert_send<T: Send>() {}
542        assert_send::<Projection>();
543    }
544
545    // r[verify projection.safety.sync]
546    #[test]
547    fn trait_sync() {
548        fn assert_sync<T: Sync>() {}
549        assert_sync::<Projection>();
550    }
551
552    // r[verify projection.safety.unpin]
553    #[test]
554    fn trait_unpin() {
555        fn assert_unpin<T: Unpin>() {}
556        assert_unpin::<Projection>();
557    }
558}