1use 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
38pub type SharedRenderer<K> = Arc<Renderer<K>>;
40
41pub 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 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 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 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}