ratatui_toolkit/primitives/termtui/
widget.rs

1//! Ratatui widget for rendering the terminal
2
3use crate::primitives::termtui::copy_mode::{CopyMode, CopyPos};
4use crate::primitives::termtui::screen::Screen;
5use ratatui::buffer::Buffer;
6use ratatui::layout::Rect;
7use ratatui::style::{Color, Modifier, Style};
8use ratatui::widgets::Widget;
9
10/// Widget for rendering a terminal screen
11pub struct TermTuiWidget<'a> {
12    /// The screen to render
13    screen: &'a Screen,
14    /// Scroll offset (0 = latest)
15    scroll_offset: usize,
16    /// Copy mode state (for rendering selection)
17    copy_mode: Option<&'a CopyMode>,
18}
19
20impl<'a> TermTuiWidget<'a> {
21    /// Create a new widget
22    pub fn new(screen: &'a Screen) -> Self {
23        Self {
24            screen,
25            scroll_offset: 0,
26            copy_mode: None,
27        }
28    }
29
30    /// Set scroll offset
31    pub fn scroll_offset(mut self, offset: usize) -> Self {
32        self.scroll_offset = offset;
33        self
34    }
35
36    /// Set copy mode for selection rendering
37    pub fn copy_mode(mut self, mode: &'a CopyMode) -> Self {
38        self.copy_mode = Some(mode);
39        self
40    }
41}
42
43impl Widget for TermTuiWidget<'_> {
44    fn render(self, area: Rect, buf: &mut Buffer) {
45        let size = self.screen.size();
46        let _screen_rows = size.rows as usize;
47        let screen_cols = size.cols as usize;
48
49        // Get selection bounds if in copy mode
50        let selection = self.copy_mode.and_then(|m| m.get_selection());
51        let copy_cursor = self.copy_mode.and_then(|m| m.cursor());
52
53        // Render each visible row
54        for (row_idx, row) in self.screen.visible_rows().enumerate() {
55            if row_idx >= area.height as usize {
56                break;
57            }
58
59            let y = area.y + row_idx as u16;
60
61            // Render each cell in the row
62            for (col_idx, cell) in row.cells().enumerate() {
63                if col_idx >= area.width as usize || col_idx >= screen_cols {
64                    break;
65                }
66
67                let x = area.x + col_idx as u16;
68
69                // Skip wide character continuations
70                if cell.is_wide_continuation() {
71                    continue;
72                }
73
74                // Get cell style
75                let mut style = cell.attrs().to_ratatui();
76
77                // Check if this cell is in selection
78                if let Some((start, end)) = &selection {
79                    let cell_y = row_idx as i32 - self.scroll_offset as i32;
80                    let cell_x = col_idx as i32;
81
82                    if is_in_selection(cell_x, cell_y, start, end) {
83                        style = Style::default()
84                            .bg(Color::Rgb(70, 130, 180))
85                            .fg(Color::White);
86                    }
87                }
88
89                // Get the character to render
90                let ch = cell.text().chars().next().unwrap_or(' ');
91
92                if let Some(buf_cell) = buf.cell_mut((x, y)) {
93                    buf_cell.set_char(ch).set_style(style);
94                }
95            }
96        }
97
98        // Render cursor
99        let cursor_pos = self.screen.cursor_pos();
100
101        // In copy mode, render copy mode cursor instead
102        if let Some(copy_cursor) = copy_cursor {
103            let cursor_row = (copy_cursor.y + self.scroll_offset as i32) as u16;
104            let cursor_col = copy_cursor.x as u16;
105
106            if cursor_row < area.height && cursor_col < area.width {
107                let x = area.x + cursor_col;
108                let y = area.y + cursor_row;
109
110                if let Some(cell) = buf.cell_mut((x, y)) {
111                    let cursor_style = Style::default()
112                        .bg(Color::Yellow)
113                        .fg(Color::Black)
114                        .add_modifier(Modifier::BOLD);
115                    cell.set_style(cursor_style);
116                }
117            }
118        } else if self.screen.cursor_visible() && self.scroll_offset == 0 {
119            // Normal cursor (only when not scrolled back)
120            let cursor_row = cursor_pos.row;
121            let cursor_col = cursor_pos.col;
122
123            if cursor_row < area.height && cursor_col < area.width {
124                let x = area.x + cursor_col;
125                let y = area.y + cursor_row;
126
127                if let Some(cell) = buf.cell_mut((x, y)) {
128                    let cursor_style = Style::default()
129                        .bg(Color::White)
130                        .fg(Color::Black)
131                        .add_modifier(Modifier::REVERSED);
132
133                    // Use block cursor if cell is empty
134                    if cell.symbol() == " " {
135                        cell.set_char('█');
136                    }
137                    cell.set_style(cursor_style);
138                }
139            }
140        }
141    }
142}
143
144/// Check if a cell position is within the selection range
145fn is_in_selection(x: i32, y: i32, start: &CopyPos, end: &CopyPos) -> bool {
146    let (low, high) = CopyPos::to_low_high(start, end);
147
148    if y < low.y || y > high.y {
149        return false;
150    }
151
152    if y == low.y && y == high.y {
153        // Single line selection
154        x >= low.x && x <= high.x
155    } else if y == low.y {
156        // First line of multi-line selection
157        x >= low.x
158    } else if y == high.y {
159        // Last line of multi-line selection
160        x <= high.x
161    } else {
162        // Middle lines - entire line selected
163        true
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn test_is_in_selection_single_line() {
173        let start = CopyPos::new(5, 10);
174        let end = CopyPos::new(15, 10);
175
176        assert!(is_in_selection(5, 10, &start, &end));
177        assert!(is_in_selection(10, 10, &start, &end));
178        assert!(is_in_selection(15, 10, &start, &end));
179        assert!(!is_in_selection(4, 10, &start, &end));
180        assert!(!is_in_selection(16, 10, &start, &end));
181        assert!(!is_in_selection(10, 9, &start, &end));
182    }
183
184    #[test]
185    fn test_is_in_selection_multi_line() {
186        let start = CopyPos::new(5, 10);
187        let end = CopyPos::new(15, 12);
188
189        // First line
190        assert!(is_in_selection(5, 10, &start, &end));
191        assert!(is_in_selection(50, 10, &start, &end)); // All of first line from start
192        assert!(!is_in_selection(4, 10, &start, &end));
193
194        // Middle line
195        assert!(is_in_selection(0, 11, &start, &end));
196        assert!(is_in_selection(50, 11, &start, &end));
197
198        // Last line
199        assert!(is_in_selection(0, 12, &start, &end));
200        assert!(is_in_selection(15, 12, &start, &end));
201        assert!(!is_in_selection(16, 12, &start, &end));
202    }
203
204    #[test]
205    fn test_is_in_selection_reversed() {
206        // Selection from bottom-right to top-left
207        let start = CopyPos::new(15, 12);
208        let end = CopyPos::new(5, 10);
209
210        // Should work the same due to to_low_high normalization
211        assert!(is_in_selection(10, 11, &start, &end));
212    }
213}