Skip to main content

rpi_tui/
component.rs

1//! Component trait and base types for TUI.
2//!
3//! Mirrors the TypeScript `Component` interface from `packages/tui/src/tui.ts`.
4
5use std::any::Any;
6use std::sync::Arc;
7
8/// Component interface - all components must implement this.
9///
10/// Components are the building blocks of the TUI. Each component can render
11/// itself to a list of strings (one per line) for a given viewport width.
12pub trait Component: Send + Sync {
13    /// Render the component to lines for the given viewport width.
14    ///
15    /// # Arguments
16    /// * `width` - Current viewport width in columns
17    ///
18    /// # Returns
19    /// Array of strings, each representing a line
20    fn render(&self, width: usize) -> Vec<String>;
21
22    /// Render shared row blocks for a scrollable document. Components with
23    /// cached output can override this to avoid copying off-screen history.
24    /// The default deliberately renders afresh: arbitrary mutable components
25    /// must never be assumed immutable merely because their identity is stable.
26    fn render_document(&self, width: usize) -> RenderedDocument {
27        RenderedDocument::from_lines(self.render(width))
28    }
29
30    /// Optional handler for keyboard input when component has focus.
31    ///
32    /// # Arguments
33    /// * `data` - Raw input data (key sequence)
34    ///
35    /// # Returns
36    /// true if the input was consumed, false otherwise
37    fn handle_input(&self, _data: &str) -> bool {
38        false
39    }
40
41    /// If true, component receives key release events (Kitty protocol).
42    /// Default is false - release events are filtered out.
43    fn wants_key_release(&self) -> bool {
44        false
45    }
46
47    /// Invalidate any cached rendering state.
48    /// Called when theme changes or when component needs to re-render from scratch.
49    fn invalidate(&self);
50
51    /// Downcast to Any for runtime type checking.
52    /// This enables the layout system to check for LayoutNodeProvider.
53    fn as_any(&self) -> &dyn Any;
54}
55
56/// A rendered document assembled from immutable, shared row blocks.
57///
58/// Composing documents copies only block handles, not historical strings.
59/// Cumulative block ends permit viewport extraction without walking every row.
60#[derive(Clone, Default)]
61pub struct RenderedDocument {
62    blocks: Vec<Arc<Vec<String>>>,
63    ends: Vec<usize>,
64}
65
66impl RenderedDocument {
67    /// Own a freshly rendered block.
68    pub fn from_lines(lines: Vec<String>) -> Self {
69        Self::from_shared(Arc::new(lines))
70    }
71
72    /// Retain cached rows without copying their contents.
73    pub fn from_shared(lines: Arc<Vec<String>>) -> Self {
74        if lines.is_empty() {
75            return Self::default();
76        }
77        Self {
78            ends: vec![lines.len()],
79            blocks: vec![lines],
80        }
81    }
82
83    /// Append another document, retaining its shared row allocations.
84    pub fn append(&mut self, other: Self) {
85        let offset = self.len();
86        self.ends
87            .extend(other.ends.into_iter().map(|end| offset + end));
88        self.blocks.extend(other.blocks);
89    }
90
91    /// Total rendered row count.
92    pub fn len(&self) -> usize {
93        self.ends.last().copied().unwrap_or(0)
94    }
95
96    /// Whether the document contains no rows.
97    pub fn is_empty(&self) -> bool {
98        self.blocks.is_empty()
99    }
100
101    /// Copy only the requested rows, typically the visible viewport.
102    pub fn lines(&self, start: usize, height: usize) -> Vec<String> {
103        let mut index = self.ends.partition_point(|end| *end <= start);
104        let mut offset = start.saturating_sub(if index == 0 { 0 } else { self.ends[index - 1] });
105        let mut visible = Vec::with_capacity(height.min(self.len().saturating_sub(start)));
106        while index < self.blocks.len() && visible.len() < height {
107            let block = &self.blocks[index];
108            visible.extend(
109                block
110                    .iter()
111                    .skip(offset)
112                    .take(height - visible.len())
113                    .cloned(),
114            );
115            index += 1;
116            offset = 0;
117        }
118        visible
119    }
120}
121
122/// Interface for components that can receive focus and display a hardware cursor.
123///
124/// When focused, the component should emit `CURSOR_MARKER` at the cursor position
125/// in its render output. TUI will find this marker and position the hardware
126/// cursor there for proper IME candidate window positioning.
127pub trait Focusable: Component {
128    /// Set by TUI when focus changes. Component should emit CURSOR_MARKER when true.
129    fn set_focused(&self, focused: bool);
130
131    /// Check if the component is currently focused.
132    fn is_focused(&self) -> bool;
133}
134
135/// Type alias for a reference-counted component.
136pub type ComponentRef = Arc<dyn Component>;
137
138/// Type alias for a reference-counted focusable component.
139pub type FocusableRef = Arc<dyn Focusable>;
140
141/// Cursor position marker - APC (Application Program Command) sequence.
142/// This is a zero-width escape sequence that terminals ignore.
143/// Components emit this at the cursor position when focused.
144/// TUI finds and strips this marker, then positions the hardware cursor there.
145pub const CURSOR_MARKER: &str = "\x1b_pi:c\x07";
146
147/// Helper to check if a component implements Focusable.
148pub fn is_focusable(component: &ComponentRef) -> bool {
149    component
150        .as_any()
151        .downcast_ref::<Arc<dyn Focusable>>()
152        .is_some()
153        || component.as_any().type_id() == std::any::TypeId::of::<Arc<dyn Focusable>>()
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[allow(dead_code)]
161    struct TestComponent {
162        content: String,
163    }
164
165    impl Component for TestComponent {
166        fn render(&self, _width: usize) -> Vec<String> {
167            vec![self.content.clone()]
168        }
169
170        fn invalidate(&self) {}
171
172        fn as_any(&self) -> &dyn Any {
173            self
174        }
175    }
176
177    #[test]
178    fn document_composition_retains_shared_rows_and_slices_across_blocks() {
179        let first = Arc::new(vec!["one".into(), "two".into()]);
180        let second = Arc::new(vec!["three".into(), "four".into()]);
181        let mut document = RenderedDocument::from_shared(first.clone());
182        document.append(RenderedDocument::default());
183        document.append(RenderedDocument::from_shared(second.clone()));
184        assert!(Arc::ptr_eq(&document.blocks[0], &first));
185        assert!(Arc::ptr_eq(&document.blocks[1], &second));
186        assert_eq!(document.len(), 4);
187        assert_eq!(document.lines(1, 2), ["two", "three"]);
188        assert_eq!(document.lines(2, usize::MAX), ["three", "four"]);
189        assert!(document.lines(4, 1).is_empty());
190        assert!(document.lines(usize::MAX, 1).is_empty());
191        assert!(document.lines(0, 0).is_empty());
192    }
193
194    #[test]
195    fn document_viewport_does_not_copy_large_history() {
196        for size in [1, 1_000, 10_000] {
197            let rows = Arc::new(vec!["historical row".repeat(64); size]);
198            let document = RenderedDocument::from_shared(rows.clone());
199            let copy = document.clone();
200            assert!(Arc::ptr_eq(&copy.blocks[0], &rows));
201            assert_eq!(copy.lines(size - 1, 1), [rows[size - 1].clone()]);
202            assert_eq!(Arc::strong_count(&rows), 3);
203        }
204    }
205
206    #[test]
207    fn test_cursor_marker() {
208        assert!(CURSOR_MARKER.starts_with("\x1b"));
209        assert!(CURSOR_MARKER.ends_with("\x07"));
210    }
211}