Skip to main content

oxi_tui/widget/
context.rs

1//! Per-frame render context passed to every `Renderable::render` call.
2//!
3//! Widgets read from `ctx` (theme, caps, time, focus) and write to it
4//! (cursor slot, link spans). The buffer is accessed via `ctx.buffer_mut()`.
5//!
6//! ## Cursor slot lifecycle
7//!
8//! 1. `begin_frame` resets `cursor` to `CursorSlot::NotSet`.
9//! 2. During `render()`, widgets call `set_cursor(pos)` or `hide_cursor()`.
10//! 3. `RetainedTree::render` calls `take_cursor_slot()` after walking the tree,
11//!    resolves via `CursorSlot::resolve(last_cursor)`, and emits to terminal
12//!    through `CursorState::reconcile`.
13
14use std::time::Instant;
15
16use ratatui::Frame;
17use ratatui::buffer::Buffer;
18use ratatui::layout::{Position, Rect};
19
20use crate::pipeline::CursorSlot;
21use crate::pipeline::diff_backend::{LinkCollector, LinkTarget};
22use crate::theme::{TerminalCaps, Theme};
23use crate::widget::CellRange;
24
25/// What has focus this frame. Affects rendering of input cursor, highlights, etc.
26#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
27pub enum FocusTarget {
28    #[default]
29    None,
30    Chat,
31    Input,
32    Overlay,
33}
34
35/// Two lifetimes are required: `'a` is the outer `&mut Frame` borrow passed
36/// into `RenderCtx::new`, and `'f` is the frame's own buffer lifetime. ratatui
37/// 0.30's `Terminal::draw` callback produces a `&mut Frame<'_>` whose buffer
38/// outlives the local borrow, so collapsing both into one lifetime does not
39/// compile. Downstream code that just uses `&RenderCtx` elides the lifetimes
40/// to `RenderCtx<'_, '_>`.
41pub struct RenderCtx<'a, 'f> {
42    frame: &'a mut Frame<'f>,
43    theme: &'a Theme,
44    caps: &'a TerminalCaps,
45    pub focus: FocusTarget,
46    pub time: Instant,
47    links: LinkCollector,
48    cursor: CursorSlot,
49}
50
51impl<'a, 'f> RenderCtx<'a, 'f> {
52    pub fn new(frame: &'a mut Frame<'f>, theme: &'a Theme, caps: &'a TerminalCaps) -> Self {
53        Self {
54            frame,
55            theme,
56            caps,
57            focus: FocusTarget::default(),
58            time: Instant::now(),
59            links: LinkCollector::new(),
60            cursor: CursorSlot::NotSet,
61        }
62    }
63
64    #[must_use]
65    pub fn theme(&self) -> &Theme {
66        self.theme
67    }
68
69    #[must_use]
70    pub fn caps(&self) -> &TerminalCaps {
71        self.caps
72    }
73
74    pub fn buffer_mut(&mut self) -> &mut Buffer {
75        self.frame.buffer_mut()
76    }
77
78    #[must_use]
79    pub fn area(&self) -> Rect {
80        self.frame.area()
81    }
82
83    pub fn set_cursor(&mut self, pos: Position) {
84        self.cursor = CursorSlot::Show(pos);
85    }
86
87    pub fn hide_cursor(&mut self) {
88        self.cursor = CursorSlot::Hide;
89    }
90
91    /// Drain the cursor slot, resetting to `NotSet`. Called by `RetainedTree`
92    /// after render to inspect what widgets requested.
93    pub(crate) fn take_cursor_slot(&mut self) -> CursorSlot {
94        std::mem::replace(&mut self.cursor, CursorSlot::NotSet)
95    }
96
97    pub fn emit_link(&mut self, range: CellRange, target: LinkTarget) {
98        self.links.add(range, target);
99    }
100
101    /// Drain collected links. Called by pipeline after render, before flush.
102    #[expect(dead_code, reason = "used by pipeline link flush in Task 9")]
103    pub(crate) fn take_links(&mut self) -> LinkCollector {
104        std::mem::take(&mut self.links)
105    }
106    /// Access the underlying `Frame` for legacy bridging.
107    ///
108    /// Used by `LegacyOverlayAdapter` to forward `Renderable::render` calls
109    /// to legacy `Overlay` implementations that operate on a raw
110    /// `&mut Frame`. NOTE: This is a temporary bridge API. Once all overlays
111    /// are migrated to `Renderable`, this accessor should be removed.
112    pub fn with_frame<R>(&mut self, f: impl FnOnce(&mut Frame<'f>) -> R) -> R {
113        f(self.frame)
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use ratatui::Terminal;
121    use ratatui::backend::TestBackend;
122
123    fn make_ctx<'a, 'f>(
124        frame: &'a mut Frame<'f>,
125        theme: &'a Theme,
126        caps: &'a TerminalCaps,
127    ) -> RenderCtx<'a, 'f> {
128        RenderCtx::new(frame, theme, caps)
129    }
130
131    #[test]
132    fn cursor_starts_notset() {
133        let backend = TestBackend::new(10, 5);
134        let mut term = Terminal::new(backend).unwrap();
135        let theme = Theme::dark();
136        let caps = TerminalCaps::default();
137        term.draw(|f| {
138            let mut ctx = make_ctx(f, &theme, &caps);
139            let slot = ctx.take_cursor_slot();
140            assert_eq!(slot, CursorSlot::NotSet);
141        })
142        .unwrap();
143    }
144
145    #[test]
146    fn set_cursor_makes_slot_show() {
147        let backend = TestBackend::new(10, 5);
148        let mut term = Terminal::new(backend).unwrap();
149        let theme = Theme::dark();
150        let caps = TerminalCaps::default();
151        term.draw(|f| {
152            let mut ctx = make_ctx(f, &theme, &caps);
153            ctx.set_cursor(Position { x: 3, y: 4 });
154            let slot = ctx.take_cursor_slot();
155            assert_eq!(slot, CursorSlot::Show(Position { x: 3, y: 4 }));
156        })
157        .unwrap();
158    }
159
160    #[test]
161    fn hide_cursor_makes_slot_hide() {
162        let backend = TestBackend::new(10, 5);
163        let mut term = Terminal::new(backend).unwrap();
164        let theme = Theme::dark();
165        let caps = TerminalCaps::default();
166        term.draw(|f| {
167            let mut ctx = make_ctx(f, &theme, &caps);
168            ctx.hide_cursor();
169            let slot = ctx.take_cursor_slot();
170            assert_eq!(slot, CursorSlot::Hide);
171        })
172        .unwrap();
173    }
174    #[test]
175    fn with_frame_provides_access_to_frame() {
176        let backend = TestBackend::new(10, 5);
177        let mut term = Terminal::new(backend).unwrap();
178        let theme = Theme::dark();
179        let caps = TerminalCaps::default();
180        term.draw(|f| {
181            let mut ctx = make_ctx(f, &theme, &caps);
182            // Verify the closure receives a `&mut Frame` and can mutate it.
183            ctx.with_frame(|frame| {
184                let area = frame.area();
185                assert_eq!(area.width, 10);
186                assert_eq!(area.height, 5);
187            });
188            // The closure can mutate; surface a sentinel symbol via the buffer
189            // and verify it landed in the underlying frame.
190            ctx.with_frame(|frame| {
191                let cell = &mut frame.buffer_mut()[(0, 0)];
192                cell.set_symbol("Z");
193            });
194            // after closure exits, ctx is still usable: another borrow works.
195            assert_eq!(ctx.area().width, 10);
196        })
197        .unwrap();
198    }
199
200    #[test]
201    fn take_resets_to_notset() {
202        let backend = TestBackend::new(10, 5);
203        let mut term = Terminal::new(backend).unwrap();
204        let theme = Theme::dark();
205        let caps = TerminalCaps::default();
206        term.draw(|f| {
207            let mut ctx = make_ctx(f, &theme, &caps);
208            ctx.set_cursor(Position { x: 0, y: 0 });
209            let _ = ctx.take_cursor_slot();
210            let slot2 = ctx.take_cursor_slot();
211            assert_eq!(slot2, CursorSlot::NotSet);
212        })
213        .unwrap();
214    }
215}