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