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}