1#![allow(dead_code, unreachable_pub)]
32
33use crate::color::ColorMode;
34use crate::direct::{CellBuffer, DiffRenderer, DirectTerminalCanvas};
35use crate::error::{TuiError, VerificationError};
36use crate::input::InputHandler;
37use crossterm::{
38 cursor,
39 event::{self, Event as CrosstermEvent, KeyCode},
40 execute,
41 terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
42};
43use presentar_core::{Constraints, Rect, Widget};
44use std::io::{self, Stdout, Write};
45use std::time::{Duration, Instant};
46
47pub trait Snapshot: Clone + Send + 'static {
61 fn empty() -> Self;
63}
64
65pub trait AsyncCollector: Send + 'static {
91 type Snapshot: Snapshot;
93
94 fn collect(&mut self) -> Self::Snapshot;
99}
100
101pub trait SnapshotReceiver {
119 type Snapshot: Snapshot;
121
122 fn apply_snapshot(&mut self, snapshot: Self::Snapshot);
127}
128
129#[derive(Debug, Clone, Default)]
133pub struct QaTimings {
134 pub input_times_us: Vec<u64>,
136 pub lock_times_us: Vec<u64>,
138 pub render_times_us: Vec<u64>,
140 pub last_collect_us: u64,
142}
143
144impl QaTimings {
145 #[must_use]
147 pub fn new() -> Self {
148 Self::default()
149 }
150
151 pub fn record_input(&mut self, duration: Duration) {
153 self.input_times_us.push(duration.as_micros() as u64);
154 }
155
156 pub fn record_lock(&mut self, duration: Duration) {
158 self.lock_times_us.push(duration.as_micros() as u64);
159 }
160
161 pub fn record_render(&mut self, duration: Duration) {
163 contract_pre_render!();
164 self.render_times_us.push(duration.as_micros() as u64);
165 }
166
167 #[must_use]
169 pub fn format_report(&self) -> String {
170 let avg = |v: &[u64]| {
171 if v.is_empty() {
172 0
173 } else {
174 v.iter().sum::<u64>() / v.len() as u64
175 }
176 };
177 let max = |v: &[u64]| v.iter().max().copied().unwrap_or(0);
178
179 format!(
180 "[QA] input: avg={}us max={}us | lock: avg={}us max={}us | render: avg={}us max={}us | collect: {}us",
181 avg(&self.input_times_us), max(&self.input_times_us),
182 avg(&self.lock_times_us), max(&self.lock_times_us),
183 avg(&self.render_times_us), max(&self.render_times_us),
184 self.last_collect_us
185 )
186 }
187
188 pub fn clear(&mut self) {
190 self.input_times_us.clear();
191 self.lock_times_us.clear();
192 self.render_times_us.clear();
193 }
194}
195
196pub trait Terminal {
202 fn enter(&mut self) -> Result<(), TuiError>;
204 fn leave(&mut self) -> Result<(), TuiError>;
206 fn size(&self) -> Result<(u16, u16), TuiError>;
208 fn poll(&self, timeout: Duration) -> Result<bool, TuiError>;
210 fn read_event(&self) -> Result<CrosstermEvent, TuiError>;
212 fn flush(
214 &mut self,
215 buffer: &mut CellBuffer,
216 renderer: &mut DiffRenderer,
217 ) -> Result<(), TuiError>;
218 fn enable_mouse(&mut self) -> Result<(), TuiError>;
220 fn disable_mouse(&mut self) -> Result<(), TuiError>;
222}
223
224pub trait TerminalBackend {
227 fn enable_raw_mode(&mut self) -> Result<(), TuiError>;
228 fn disable_raw_mode(&mut self) -> Result<(), TuiError>;
229 fn enter_alternate_screen(&mut self) -> Result<(), TuiError>;
230 fn leave_alternate_screen(&mut self) -> Result<(), TuiError>;
231 fn hide_cursor(&mut self) -> Result<(), TuiError>;
232 fn show_cursor(&mut self) -> Result<(), TuiError>;
233 fn size(&self) -> Result<(u16, u16), TuiError>;
234 fn poll(&self, timeout: Duration) -> Result<bool, TuiError>;
235 fn read_event(&self) -> Result<CrosstermEvent, TuiError>;
236 fn write_flush(
237 &mut self,
238 buffer: &mut CellBuffer,
239 renderer: &mut DiffRenderer,
240 ) -> Result<(), TuiError>;
241 fn enable_mouse_capture(&mut self) -> Result<(), TuiError>;
242 fn disable_mouse_capture(&mut self) -> Result<(), TuiError>;
243}
244
245pub struct CrosstermBackend {
247 stdout: Stdout,
248}
249
250impl CrosstermBackend {
251 pub fn new() -> Self {
252 Self {
253 stdout: io::stdout(),
254 }
255 }
256}
257
258impl Default for CrosstermBackend {
259 fn default() -> Self {
260 Self::new()
261 }
262}
263
264impl TerminalBackend for CrosstermBackend {
265 fn enable_raw_mode(&mut self) -> Result<(), TuiError> {
266 enable_raw_mode()?;
267 Ok(())
268 }
269 fn disable_raw_mode(&mut self) -> Result<(), TuiError> {
270 let _ = disable_raw_mode();
271 Ok(())
272 }
273 fn enter_alternate_screen(&mut self) -> Result<(), TuiError> {
274 execute!(self.stdout, EnterAlternateScreen)?;
275 Ok(())
276 }
277 fn leave_alternate_screen(&mut self) -> Result<(), TuiError> {
278 let _ = execute!(self.stdout, LeaveAlternateScreen);
279 Ok(())
280 }
281 fn hide_cursor(&mut self) -> Result<(), TuiError> {
282 execute!(self.stdout, cursor::Hide)?;
283 Ok(())
284 }
285 fn show_cursor(&mut self) -> Result<(), TuiError> {
286 let _ = execute!(self.stdout, cursor::Show);
287 Ok(())
288 }
289 fn size(&self) -> Result<(u16, u16), TuiError> {
290 Ok(crossterm::terminal::size()?)
291 }
292 fn poll(&self, timeout: Duration) -> Result<bool, TuiError> {
293 Ok(event::poll(timeout)?)
294 }
295 fn read_event(&self) -> Result<CrosstermEvent, TuiError> {
296 Ok(event::read()?)
297 }
298 fn write_flush(
299 &mut self,
300 buffer: &mut CellBuffer,
301 renderer: &mut DiffRenderer,
302 ) -> Result<(), TuiError> {
303 renderer.flush(buffer, &mut self.stdout)?;
304 self.stdout.flush()?;
305 Ok(())
306 }
307 fn enable_mouse_capture(&mut self) -> Result<(), TuiError> {
308 execute!(self.stdout, crossterm::event::EnableMouseCapture)?;
309 Ok(())
310 }
311 fn disable_mouse_capture(&mut self) -> Result<(), TuiError> {
312 let _ = execute!(self.stdout, crossterm::event::DisableMouseCapture);
313 Ok(())
314 }
315}
316
317#[allow(clippy::struct_excessive_bools)]
320pub struct TestableBackend<W: Write> {
321 writer: W,
322 size: (u16, u16),
323 raw_mode: bool,
324 alternate_screen: bool,
325 cursor_hidden: bool,
326 mouse_captured: bool,
327 events: std::cell::RefCell<std::collections::VecDeque<CrosstermEvent>>,
328 poll_results: std::cell::RefCell<std::collections::VecDeque<bool>>,
329}
330
331impl<W: Write> TestableBackend<W> {
332 pub fn new(writer: W, width: u16, height: u16) -> Self {
334 Self {
335 writer,
336 size: (width, height),
337 raw_mode: false,
338 alternate_screen: false,
339 cursor_hidden: false,
340 mouse_captured: false,
341 events: std::cell::RefCell::new(std::collections::VecDeque::new()),
342 poll_results: std::cell::RefCell::new(std::collections::VecDeque::new()),
343 }
344 }
345
346 pub fn with_events(self, events: Vec<CrosstermEvent>) -> Self {
348 *self.events.borrow_mut() = events.into_iter().collect();
349 self
350 }
351
352 pub fn with_polls(self, polls: Vec<bool>) -> Self {
354 *self.poll_results.borrow_mut() = polls.into_iter().collect();
355 self
356 }
357
358 pub fn is_raw_mode(&self) -> bool {
360 self.raw_mode
361 }
362
363 pub fn is_alternate_screen(&self) -> bool {
365 self.alternate_screen
366 }
367
368 pub fn is_cursor_hidden(&self) -> bool {
370 self.cursor_hidden
371 }
372
373 pub fn is_mouse_captured(&self) -> bool {
375 self.mouse_captured
376 }
377
378 pub fn into_writer(self) -> W {
380 self.writer
381 }
382}
383
384impl<W: Write> TerminalBackend for TestableBackend<W> {
385 fn enable_raw_mode(&mut self) -> Result<(), TuiError> {
386 self.raw_mode = true;
387 Ok(())
388 }
389
390 fn disable_raw_mode(&mut self) -> Result<(), TuiError> {
391 self.raw_mode = false;
392 Ok(())
393 }
394
395 fn enter_alternate_screen(&mut self) -> Result<(), TuiError> {
396 self.alternate_screen = true;
397 execute!(self.writer, EnterAlternateScreen)?;
399 Ok(())
400 }
401
402 fn leave_alternate_screen(&mut self) -> Result<(), TuiError> {
403 self.alternate_screen = false;
404 let _ = execute!(self.writer, LeaveAlternateScreen);
405 Ok(())
406 }
407
408 fn hide_cursor(&mut self) -> Result<(), TuiError> {
409 self.cursor_hidden = true;
410 execute!(self.writer, cursor::Hide)?;
411 Ok(())
412 }
413
414 fn show_cursor(&mut self) -> Result<(), TuiError> {
415 self.cursor_hidden = false;
416 let _ = execute!(self.writer, cursor::Show);
417 Ok(())
418 }
419
420 fn size(&self) -> Result<(u16, u16), TuiError> {
421 Ok(self.size)
422 }
423
424 fn poll(&self, _timeout: Duration) -> Result<bool, TuiError> {
425 Ok(self.poll_results.borrow_mut().pop_front().unwrap_or(false))
426 }
427
428 fn read_event(&self) -> Result<CrosstermEvent, TuiError> {
429 self.events
430 .borrow_mut()
431 .pop_front()
432 .ok_or_else(|| TuiError::Io(io::Error::new(io::ErrorKind::WouldBlock, "no events")))
433 }
434
435 fn write_flush(
436 &mut self,
437 buffer: &mut CellBuffer,
438 renderer: &mut DiffRenderer,
439 ) -> Result<(), TuiError> {
440 renderer.flush(buffer, &mut self.writer)?;
441 self.writer.flush()?;
442 Ok(())
443 }
444
445 fn enable_mouse_capture(&mut self) -> Result<(), TuiError> {
446 self.mouse_captured = true;
447 execute!(self.writer, crossterm::event::EnableMouseCapture)?;
448 Ok(())
449 }
450
451 fn disable_mouse_capture(&mut self) -> Result<(), TuiError> {
452 self.mouse_captured = false;
453 let _ = execute!(self.writer, crossterm::event::DisableMouseCapture);
454 Ok(())
455 }
456}
457
458pub struct GenericTerminal<B: TerminalBackend> {
460 backend: B,
461}
462
463impl<B: TerminalBackend> GenericTerminal<B> {
464 pub fn new(backend: B) -> Self {
465 Self { backend }
466 }
467}
468
469impl<B: TerminalBackend> Terminal for GenericTerminal<B> {
470 fn enter(&mut self) -> Result<(), TuiError> {
471 self.backend.enable_raw_mode()?;
472 self.backend.enter_alternate_screen()?;
473 self.backend.hide_cursor()?;
474 Ok(())
475 }
476
477 fn leave(&mut self) -> Result<(), TuiError> {
478 self.backend.show_cursor()?;
479 self.backend.leave_alternate_screen()?;
480 self.backend.disable_raw_mode()?;
481 Ok(())
482 }
483
484 fn size(&self) -> Result<(u16, u16), TuiError> {
485 self.backend.size()
486 }
487
488 fn poll(&self, timeout: Duration) -> Result<bool, TuiError> {
489 self.backend.poll(timeout)
490 }
491
492 fn read_event(&self) -> Result<CrosstermEvent, TuiError> {
493 self.backend.read_event()
494 }
495
496 fn flush(
497 &mut self,
498 buffer: &mut CellBuffer,
499 renderer: &mut DiffRenderer,
500 ) -> Result<(), TuiError> {
501 self.backend.write_flush(buffer, renderer)
502 }
503
504 fn enable_mouse(&mut self) -> Result<(), TuiError> {
505 self.backend.enable_mouse_capture()
506 }
507
508 fn disable_mouse(&mut self) -> Result<(), TuiError> {
509 self.backend.disable_mouse_capture()
510 }
511}
512
513pub type CrosstermTerminal = GenericTerminal<CrosstermBackend>;
515
516#[derive(Debug, Clone)]
518pub struct TuiConfig {
519 pub tick_rate_ms: u64,
521 pub enable_mouse: bool,
523 pub color_mode: Option<ColorMode>,
525 pub skip_verification: bool,
527 pub target_fps: u32,
529}
530
531impl Default for TuiConfig {
532 fn default() -> Self {
533 Self {
534 tick_rate_ms: 250,
535 enable_mouse: false,
536 color_mode: None,
537 target_fps: 60,
538 skip_verification: false,
539 }
540 }
541}
542
543impl TuiConfig {
544 #[must_use]
546 pub fn high_performance() -> Self {
547 Self {
548 tick_rate_ms: 16,
549 target_fps: 60,
550 ..Default::default()
551 }
552 }
553
554 #[must_use]
556 pub fn power_saving() -> Self {
557 Self {
558 tick_rate_ms: 100,
559 target_fps: 30,
560 ..Default::default()
561 }
562 }
563}
564
565#[derive(Debug, Clone, Default)]
567pub struct FrameMetrics {
568 pub verify_time: Duration,
570 pub measure_time: Duration,
572 pub layout_time: Duration,
574 pub paint_time: Duration,
576 pub total_time: Duration,
578 pub frame_count: u64,
580}
581
582pub struct TuiApp<W: Widget> {
584 root: W,
585 config: TuiConfig,
586 input_handler: InputHandler,
587 metrics: FrameMetrics,
588 should_quit: bool,
589 color_mode: ColorMode,
590}
591
592impl<W: Widget> std::fmt::Debug for TuiApp<W> {
593 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
594 f.debug_struct("TuiApp")
595 .field("config", &self.config)
596 .field("input_handler", &self.input_handler)
597 .field("metrics", &self.metrics)
598 .field("should_quit", &self.should_quit)
599 .field("color_mode", &self.color_mode)
600 .finish_non_exhaustive()
601 }
602}
603
604struct AppRunner<'a, W: Widget, T: Terminal> {
606 app: &'a mut TuiApp<W>,
607 terminal: T,
608 buffer: CellBuffer,
609 renderer: DiffRenderer,
610}
611
612impl<W: Widget, T: Terminal> AppRunner<'_, W, T> {
613 fn run_loop(&mut self) -> Result<(), TuiError> {
614 let tick_duration = Duration::from_millis(self.app.config.tick_rate_ms);
615
616 loop {
617 let frame_start = Instant::now();
618
619 let (width, height) = self.terminal.size()?;
621 if width != self.buffer.width() || height != self.buffer.height() {
622 self.buffer.resize(width, height);
623 self.renderer.reset();
624 }
625
626 let verify_start = Instant::now();
628 if !self.app.config.skip_verification {
629 let verification = self.app.root.verify();
630 if !verification.is_valid() {
631 return Err(TuiError::VerificationFailed(VerificationError::from(
632 verification,
633 )));
634 }
635 }
636 self.app.metrics.verify_time = verify_start.elapsed();
637
638 self.app.render_frame(&mut self.buffer);
640
641 self.terminal.flush(&mut self.buffer, &mut self.renderer)?;
643
644 self.app.metrics.total_time = frame_start.elapsed();
645 self.app.metrics.frame_count += 1;
646
647 if self.terminal.poll(tick_duration)? {
649 if let CrosstermEvent::Key(key) = self.terminal.read_event()? {
650 if key.code == KeyCode::Char('q')
651 || key.code == KeyCode::Char('c')
652 && key
653 .modifiers
654 .contains(crossterm::event::KeyModifiers::CONTROL)
655 {
656 self.app.should_quit = true;
657 }
658
659 if let Some(event) = self.app.input_handler.convert(CrosstermEvent::Key(key)) {
660 let _ = self.app.root.event(&event);
661 }
662 }
663 }
664
665 if self.app.should_quit {
666 break;
667 }
668 }
669
670 Ok(())
671 }
672}
673
674impl<W: Widget> TuiApp<W> {
675 pub fn new(root: W) -> Result<Self, TuiError> {
677 if root.assertions().is_empty() {
679 return Err(TuiError::InvalidBrick(
680 "Root widget has no assertions - every Brick must have at least one falsifiable assertion".to_string(),
681 ));
682 }
683
684 Ok(Self {
685 root,
686 config: TuiConfig::default(),
687 input_handler: InputHandler::new(),
688 metrics: FrameMetrics::default(),
689 should_quit: false,
690 color_mode: ColorMode::detect(),
691 })
692 }
693
694 #[must_use]
696 pub fn with_config(mut self, config: TuiConfig) -> Self {
697 if let Some(mode) = config.color_mode {
698 self.color_mode = mode;
699 }
700 self.config = config;
701 self
702 }
703
704 #[must_use]
706 pub fn with_input_handler(mut self, handler: InputHandler) -> Self {
707 self.input_handler = handler;
708 self
709 }
710
711 #[must_use]
713 pub fn root(&self) -> &W {
714 &self.root
715 }
716
717 pub fn root_mut(&mut self) -> &mut W {
719 &mut self.root
720 }
721
722 #[must_use]
724 pub fn metrics(&self) -> &FrameMetrics {
725 &self.metrics
726 }
727
728 pub fn quit(&mut self) {
730 self.should_quit = true;
731 }
732
733 pub fn run(&mut self) -> Result<(), TuiError> {
735 let backend = CrosstermBackend::new();
736 let terminal = GenericTerminal::new(backend);
737 self.run_with_terminal(terminal)
738 }
739
740 pub fn run_with_terminal<T: Terminal>(&mut self, mut terminal: T) -> Result<(), TuiError> {
743 terminal.enter()?;
744
745 if self.config.enable_mouse {
746 terminal.enable_mouse()?;
747 }
748
749 let (width, height) = terminal.size()?;
751 let buffer = CellBuffer::new(width, height);
752 let renderer = DiffRenderer::with_color_mode(self.color_mode);
753
754 let mut runner = AppRunner {
755 app: self,
756 terminal,
757 buffer,
758 renderer,
759 };
760
761 let result = runner.run_loop();
762
763 if runner.app.config.enable_mouse {
764 runner.terminal.disable_mouse()?;
765 }
766 runner.terminal.leave()?;
767
768 result
769 }
770
771 fn render_frame(&mut self, buffer: &mut CellBuffer) {
772 let width = buffer.width();
773 let height = buffer.height();
774
775 let measure_start = Instant::now();
777 let constraints = Constraints::new(0.0, f32::from(width), 0.0, f32::from(height));
778 let _size = self.root.measure(constraints);
779 self.metrics.measure_time = measure_start.elapsed();
780
781 let layout_start = Instant::now();
783 let bounds = Rect::new(0.0, 0.0, f32::from(width), f32::from(height));
784 let _ = self.root.layout(bounds);
785 self.metrics.layout_time = layout_start.elapsed();
786
787 let paint_start = Instant::now();
789 {
790 let mut canvas = DirectTerminalCanvas::new(buffer);
791 self.root.paint(&mut canvas);
792 }
793 self.metrics.paint_time = paint_start.elapsed();
794 }
795}
796
797#[cfg(test)]
798#[allow(clippy::unwrap_used, clippy::disallowed_methods)]
799#[path = "app_tests.rs"]
800mod tests;