Skip to main content

promkit_core/
render.rs

1//! Terminal-dependent layout, viewport management, drawing, and hit testing.
2//!
3//! [`Renderer`] stores widget outputs in key order. During [`Renderer::render`]
4//! it reads the current terminal size, wraps or truncates every logical content
5//! row, allocates vertical viewports, scrolls each keyed viewport just enough to
6//! include its cursor, and delegates the resulting visible rows to the terminal.
7//!
8//! Empty items and items with `max_height == Some(0)` do not occupy space.
9//! Remaining items are allocated in key order while reserving at least one row
10//! for every later non-empty item. A terminal that cannot provide one row per
11//! non-empty item produces an error.
12//!
13//! A successful render saves a layout snapshot. [`Renderer::hit_test`] and
14//! [`Renderer::screen_position`] always use that snapshot, so event handling maps
15//! positions against what was actually drawn rather than against newer,
16//! not-yet-rendered content.
17
18use std::{
19    sync::{Arc, Mutex, RwLock},
20    time::{Duration, Instant},
21};
22
23use crossbeam_skiplist::SkipMap;
24use tokio::sync::Mutex as AsyncMutex;
25
26use crate::{
27    terminal::Terminal,
28    widget::{CreatedGraphemes, ScreenPosition, WidgetPosition},
29};
30
31mod layout;
32use layout::LayoutSnapshot;
33pub use layout::{PreparedLayout, RendererLayout};
34
35const RESIZE_SIZE_STABILITY: Duration = Duration::from_millis(20);
36const RESIZE_SIZE_POLL: Duration = Duration::from_millis(5);
37
38/// SharedRenderer is a type alias for an Arc-wrapped Renderer, allowing for shared ownership and concurrency.
39pub type SharedRenderer<K> = Arc<Renderer<K>>;
40
41/// Renderer stores widget content, lays it out, and draws it to a terminal.
42pub struct Renderer<K: Clone + Ord + Send + Sync + 'static> {
43    terminal: AsyncMutex<Terminal>,
44    contents: SkipMap<K, CreatedGraphemes>,
45    layout_engine: Mutex<RendererLayout<K>>,
46    layout: RwLock<Option<LayoutSnapshot<K>>>,
47    last_terminal_size: Mutex<Option<(u16, u16)>>,
48}
49
50impl<K: Clone + Ord + Send + Sync + 'static> Renderer<K> {
51    pub fn try_new() -> anyhow::Result<Self> {
52        Ok(Self {
53            terminal: AsyncMutex::new(Terminal::new(crate::crossterm::cursor::position()?)),
54            contents: SkipMap::new(),
55            layout_engine: Mutex::new(RendererLayout::default()),
56            layout: RwLock::new(None),
57            last_terminal_size: Mutex::new(None),
58        })
59    }
60
61    pub async fn try_new_with_graphemes<I, G>(init: I, draw: bool) -> anyhow::Result<Self>
62    where
63        I: IntoIterator<Item = (K, G)>,
64        G: Into<CreatedGraphemes>,
65    {
66        let renderer = Self::try_new()?;
67        renderer.update(init);
68        if draw {
69            renderer.render().await?;
70        }
71        Ok(renderer)
72    }
73
74    pub fn update<I, G>(&self, items: I) -> &Self
75    where
76        I: IntoIterator<Item = (K, G)>,
77        G: Into<CreatedGraphemes>,
78    {
79        items.into_iter().for_each(|(index, graphemes)| {
80            self.contents.insert(index, graphemes.into());
81        });
82        self
83    }
84
85    pub fn remove<I>(&self, items: I) -> &Self
86    where
87        I: IntoIterator<Item = K>,
88    {
89        let mut layout_engine = self.layout_engine.lock().expect("layout lock poisoned");
90        items.into_iter().for_each(|index| {
91            self.contents.remove(&index);
92            layout_engine.remove(&index);
93        });
94        self
95    }
96
97    /// Returns the content position under a terminal screen position.
98    ///
99    /// This always uses the layout from the most recently completed render.
100    pub fn hit_test(&self, position: ScreenPosition) -> Option<WidgetPosition<K>> {
101        let layout = self.layout.read().ok()?;
102        let layout = layout.as_ref()?;
103
104        if position.column >= layout.terminal_width {
105            return None;
106        }
107
108        let entry = layout.entries.iter().find(|entry| {
109            let start = entry.viewport.screen_row;
110            let end = start.saturating_add(entry.viewport.height);
111            position.row >= start && position.row < end
112        })?;
113
114        let relative_row = position.row.saturating_sub(entry.viewport.screen_row) as usize;
115        let visual_row = entry.viewport.content_row.saturating_add(relative_row);
116        let row = entry.rows.get(visual_row)?;
117
118        let screen_column = if position.row == layout.origin.row {
119            position.column.checked_sub(layout.origin.column)?
120        } else {
121            position.column
122        };
123
124        Some(WidgetPosition {
125            index: entry.index.clone(),
126            row: row.content_row,
127            column: row.content_column.saturating_add(screen_column as usize),
128        })
129    }
130
131    /// Returns the screen position for a widget content position when it is visible.
132    pub fn screen_position(&self, position: WidgetPosition<K>) -> Option<ScreenPosition> {
133        let layout = self.layout.read().ok()?;
134        let layout = layout.as_ref()?;
135        let entry = layout
136            .entries
137            .iter()
138            .find(|entry| entry.index == position.index)?;
139
140        let matching_rows = entry
141            .rows
142            .iter()
143            .enumerate()
144            .filter(|(_, row)| row.content_row == position.row)
145            .collect::<Vec<_>>();
146
147        let (visual_row, row) = matching_rows
148            .iter()
149            .copied()
150            .find(|(_, row)| {
151                let end = row.content_column.saturating_add(row.graphemes.widths());
152                position.column >= row.content_column && position.column < end
153            })
154            .or_else(|| matching_rows.last().copied())?;
155
156        let viewport_end = entry
157            .viewport
158            .content_row
159            .saturating_add(entry.viewport.height as usize);
160        if visual_row < entry.viewport.content_row || visual_row >= viewport_end {
161            return None;
162        }
163
164        let row_offset = visual_row.saturating_sub(entry.viewport.content_row) as u16;
165        let screen_row = entry.viewport.screen_row.saturating_add(row_offset);
166        let row_origin_column = if screen_row == layout.origin.row {
167            layout.origin.column
168        } else {
169            0
170        };
171        let column_offset = position.column.saturating_sub(row.content_column);
172        let column_offset = u16::try_from(column_offset).ok()?;
173        let column = row_origin_column.saturating_add(column_offset);
174
175        (column < layout.terminal_width).then_some(ScreenPosition {
176            row: screen_row,
177            column,
178        })
179    }
180
181    /// Lays out all current widget outputs and renders their visible viewports.
182    ///
183    /// Viewport offsets persist by item key. They remain stable while a cursor is
184    /// visible, move only when it crosses a viewport edge, and are clamped when
185    /// content or terminal dimensions shrink.
186    pub async fn render(&self) -> anyhow::Result<()> {
187        let contents = self
188            .contents
189            .iter()
190            .map(|entry| (entry.key().clone(), entry.value().clone()))
191            .collect::<Vec<_>>();
192
193        let mut terminal = self.terminal.lock().await;
194        let mut size = crate::crossterm::terminal::size()?;
195        let previous_size = *self
196            .last_terminal_size
197            .lock()
198            .expect("terminal size lock poisoned");
199        if previous_size.is_some_and(|previous| previous != size) {
200            size = wait_for_terminal_size_stability(size).await?;
201        }
202
203        loop {
204            let (terminal_width, terminal_height) = size;
205            let prepared = self
206                .layout_engine
207                .lock()
208                .expect("layout lock poisoned")
209                .layout(contents.iter().cloned(), terminal_width, terminal_height)?;
210
211            let panes = prepared.panes();
212            terminal.draw_rows_at_height(&panes, terminal_height)?;
213            drop(panes);
214
215            let current_size = crate::crossterm::terminal::size()?;
216            if current_size != size {
217                size = wait_for_terminal_size_stability(current_size).await?;
218                continue;
219            }
220
221            let origin = ScreenPosition {
222                row: terminal.position.1,
223                column: terminal.position.0,
224            };
225            *self.layout.write().expect("layout lock poisoned") =
226                Some(prepared.into_snapshot(origin));
227            *self
228                .last_terminal_size
229                .lock()
230                .expect("terminal size lock poisoned") = Some(size);
231
232            return Ok(());
233        }
234    }
235}
236
237async fn wait_for_terminal_size_stability(mut previous: (u16, u16)) -> std::io::Result<(u16, u16)> {
238    let mut stable_since = Instant::now();
239
240    loop {
241        tokio::time::sleep(RESIZE_SIZE_POLL).await;
242        let current = crate::crossterm::terminal::size()?;
243        if current != previous {
244            previous = current;
245            stable_since = Instant::now();
246        } else if stable_since.elapsed() >= RESIZE_SIZE_STABILITY {
247            return Ok(current);
248        }
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    use crate::{grapheme::StyledGraphemes, widget::WidgetViewport};
256
257    mod hit_test {
258        use super::*;
259
260        #[test]
261        fn maps_screen_positions_back_to_widget_positions() {
262            let renderer = Renderer {
263                terminal: AsyncMutex::new(Terminal::new((0, 0))),
264                contents: SkipMap::new(),
265                layout_engine: Mutex::new(RendererLayout::default()),
266                layout: RwLock::new(Some(LayoutSnapshot {
267                    origin: ScreenPosition { row: 3, column: 0 },
268                    terminal_width: 20,
269                    entries: vec![layout::LayoutEntry {
270                        index: 7usize,
271                        viewport: WidgetViewport {
272                            screen_row: 3,
273                            height: 2,
274                            content_row: 1,
275                        },
276                        rows: vec![
277                            layout::VisualRow {
278                                content_row: 0,
279                                content_column: 0,
280                                graphemes: StyledGraphemes::from("hidden"),
281                            },
282                            layout::VisualRow {
283                                content_row: 1,
284                                content_column: 0,
285                                graphemes: StyledGraphemes::from("first"),
286                            },
287                            layout::VisualRow {
288                                content_row: 2,
289                                content_column: 0,
290                                graphemes: StyledGraphemes::from("second"),
291                            },
292                        ],
293                    }],
294                })),
295                last_terminal_size: Mutex::new(None),
296            };
297
298            let screen = ScreenPosition { row: 4, column: 2 };
299            let widget = renderer.hit_test(screen).unwrap();
300            assert_eq!(
301                widget,
302                WidgetPosition {
303                    index: 7,
304                    row: 2,
305                    column: 2,
306                }
307            );
308            assert_eq!(renderer.screen_position(widget), Some(screen));
309        }
310    }
311}