1use 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
42pub type SharedRenderer<K> = Arc<Renderer<K>>;
44
45pub 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 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 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 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}