Skip to main content

oxi_tui/pipeline/
mod.rs

1//! Terminal-first frame lifecycle.
2//!
3//! Decomposes ratatui's `Terminal::draw()` so the application owns the
4//! cursor-emission decision. One frame is:
5//! `autoresize → hash-skip → render → flush(DiffBackend) → reconcile cursor → swap`.
6//!
7//! ## Two entry points
8//!
9//! - [`draw_frame`] — the retained path. Pass a
10//!   [`RetainedTree`] whose root hash is
11//!   checked each frame; if unchanged (and no resize), rendering and
12//!   flushing are skipped entirely. This is the target API.
13//! - [`draw_frame_closure`] — the cutover path. Takes a transient render
14//!   closure instead of a retained tree, so there is no hash to memoize
15//!   (it always renders). Used while widgets are still being migrated to
16//!   [`Renderable`][crate::widget::Renderable]; still preserves cursor
17//!   dedup, CSI 2026 sync, and DECCARA background fill.
18//!
19//! Both preserve [`CursorState`] dedup (same cursor position emits zero
20//! bytes) and wrap diffed writes in CSI 2026 synchronized output. See spec §4.
21
22pub mod cursor;
23pub mod cursor_slot;
24pub mod diff_backend;
25
26pub use cursor::CursorState;
27pub use cursor_slot::CursorSlot;
28pub use diff_backend::DiffBackend;
29
30use ratatui::Terminal;
31use ratatui::backend::Backend;
32
33use crate::theme::{TerminalCaps, Theme};
34use crate::widget::{FocusTarget, RenderCtx, RetainedTree};
35
36/// Draws one terminal-first frame, skipping unchanged content when not resized.
37pub fn draw_frame<B: Backend>(
38    term: &mut Terminal<B>,
39    tree: &mut RetainedTree,
40    cursor: &mut CursorState,
41    focus: FocusTarget,
42    theme: &Theme,
43    caps: &TerminalCaps,
44) -> Result<FrameOutcome, B::Error> {
45    let prev_size = term.size()?;
46    term.autoresize()?;
47    let resized = term.size()? != prev_size;
48    if !tree.any_hash_changed() && !resized {
49        return Ok(FrameOutcome::Idle);
50    }
51    let want = {
52        let mut frame = term.get_frame();
53        let mut ctx = RenderCtx::new(&mut frame, theme, caps);
54        ctx.focus = focus;
55        tree.render(&mut ctx)
56    };
57    term.flush()?;
58    cursor.reconcile(want, term)?;
59    term.swap_buffers();
60    term.backend_mut().flush()?;
61    Ok(FrameOutcome::Rendered)
62}
63
64/// Like `draw_frame` but takes a transient render closure instead of a `RetainedTree`.
65///
66/// Used for the cutover phase: preserves cursor blink, CSI 2026 sync, and
67/// DECCARA background fill but skips hash-memoization (always renders the
68/// closure, since the closure has no retained subtree to hash).
69///
70/// The `render_fn` is `FnOnce(&mut RenderCtx)` and is called synchronously.
71/// Callers that need to capture `&mut state` (or `&theme`) can do so safely:
72/// the borrow lives only for the duration of this call, with no event
73/// handling or other state access in between.
74///
75/// Once oxi-cli fully migrates to `RetainedTree`, this function is removed.
76pub fn draw_frame_closure<B, F>(
77    term: &mut Terminal<B>,
78    cursor: &mut CursorState,
79    focus: FocusTarget,
80    theme: &Theme,
81    caps: &TerminalCaps,
82    render_fn: F,
83) -> Result<FrameOutcome, B::Error>
84where
85    B: Backend,
86    F: FnOnce(&mut RenderCtx<'_, '_>),
87{
88    // 1. Autoresize so `get_frame()` sees the current terminal size.
89    term.autoresize()?;
90
91    // 2. Render via the closure — always, no hash skip in cutover mode.
92    //    The cursor slot drains inside this block; `None` is passed as the
93    //    fallback because cutover mode does not retain a last cursor.
94    let want = {
95        let mut frame = term.get_frame();
96        let mut ctx = RenderCtx::new(&mut frame, theme, caps);
97        ctx.focus = focus;
98        render_fn(&mut ctx);
99        ctx.take_cursor_slot().resolve(None)
100    };
101
102    // 3. Flush diffed cells + CSI 2026 sync.
103    term.flush()?;
104
105    // 4. Reconcile cursor — emits zero bytes if already in the desired state.
106    cursor.reconcile(want, term)?;
107
108    // 5. Swap buffers and write any remaining bytes.
109    term.swap_buffers();
110    term.backend_mut().flush()?;
111
112    Ok(FrameOutcome::Rendered)
113}
114
115/// Outcome of a single `draw_frame` call. Lets the caller sleep until the next
116/// tick when nothing changed (idle skip — spec §1.4 proactive optimization).
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
118pub enum FrameOutcome {
119    /// No work was done: `content_hash` unchanged, no resize, no cursor change.
120    /// Caller may sleep until the next event/tick.
121    #[default]
122    Idle,
123    /// A frame was rendered. Cell diff may or may not have emitted bytes
124    /// (`DiffBackend` knows, but pipeline doesn't need to).
125    Rendered,
126}
127
128#[cfg(test)]
129mod draw_frame_tests {
130    use super::*;
131    use crate::widget::{RetainedTree, Text};
132    use ratatui::layout::Position;
133    use ratatui::{Terminal, backend::TestBackend};
134
135    fn make_term() -> Terminal<TestBackend> {
136        Terminal::new(TestBackend::new(40, 10)).unwrap()
137    }
138
139    #[test]
140    fn first_frame_is_rendered() {
141        let mut term = make_term();
142        let mut tree = RetainedTree::new(Box::new(Text::new("hello")));
143        let mut cursor = CursorState::new();
144        let theme = Theme::dark();
145        let caps = TerminalCaps::default();
146        let outcome = draw_frame(
147            &mut term,
148            &mut tree,
149            &mut cursor,
150            crate::widget::FocusTarget::None,
151            &theme,
152            &caps,
153        )
154        .unwrap();
155        assert_eq!(outcome, FrameOutcome::Rendered);
156    }
157
158    #[test]
159    fn second_frame_with_unchanged_hash_is_idle() {
160        let mut term = make_term();
161        let mut tree = RetainedTree::new(Box::new(Text::new("hello")));
162        let mut cursor = CursorState::new();
163        let theme = Theme::dark();
164        let caps = TerminalCaps::default();
165        let _ = draw_frame(
166            &mut term,
167            &mut tree,
168            &mut cursor,
169            crate::widget::FocusTarget::None,
170            &theme,
171            &caps,
172        )
173        .unwrap();
174        let outcome = draw_frame(
175            &mut term,
176            &mut tree,
177            &mut cursor,
178            crate::widget::FocusTarget::None,
179            &theme,
180            &caps,
181        )
182        .unwrap();
183        assert_eq!(outcome, FrameOutcome::Idle);
184    }
185
186    #[test]
187    fn draw_frame_closure_renders_via_fn() {
188        let mut term = make_term();
189        let mut cursor = CursorState::new();
190        let theme = Theme::dark();
191        let caps = TerminalCaps::default();
192        let mut rendered_with = None;
193        let outcome = draw_frame_closure(
194            &mut term,
195            &mut cursor,
196            crate::widget::FocusTarget::None,
197            &theme,
198            &caps,
199            |ctx| {
200                rendered_with = Some(ctx.area().width);
201                ctx.set_cursor(Position { x: 0, y: 0 });
202            },
203        )
204        .unwrap();
205        assert_eq!(outcome, FrameOutcome::Rendered);
206        assert_eq!(rendered_with, Some(40));
207    }
208
209    #[test]
210    fn draw_frame_closure_keeps_compiling_without_retained_tree() {
211        // Second call on the same closure-based flow still renders (no Idle skip).
212        let mut term = make_term();
213        let mut cursor = CursorState::new();
214        let theme = Theme::dark();
215        let caps = TerminalCaps::default();
216        let outcome = draw_frame_closure(
217            &mut term,
218            &mut cursor,
219            crate::widget::FocusTarget::None,
220            &theme,
221            &caps,
222            |_ctx| {},
223        )
224        .unwrap();
225        assert_eq!(outcome, FrameOutcome::Rendered);
226    }
227}