use std::{
sync::{Arc, Mutex, RwLock},
time::{Duration, Instant},
};
use crossbeam_skiplist::SkipMap;
use tokio::sync::Mutex as AsyncMutex;
use crate::{
terminal::Terminal,
widget::{CreatedGraphemes, ScreenPosition, WidgetPosition},
};
mod layout;
use layout::LayoutSnapshot;
pub use layout::{PreparedLayout, RendererLayout};
const RESIZE_SIZE_STABILITY: Duration = Duration::from_millis(20);
const RESIZE_SIZE_POLL: Duration = Duration::from_millis(5);
pub type SharedRenderer<K> = Arc<Renderer<K>>;
pub struct Renderer<K: Clone + Ord + Send + Sync + 'static> {
terminal: AsyncMutex<Terminal>,
contents: SkipMap<K, CreatedGraphemes>,
layout_engine: Mutex<RendererLayout<K>>,
layout: RwLock<Option<LayoutSnapshot<K>>>,
last_terminal_size: Mutex<Option<(u16, u16)>>,
}
impl<K: Clone + Ord + Send + Sync + 'static> Renderer<K> {
pub fn try_new() -> anyhow::Result<Self> {
Ok(Self {
terminal: AsyncMutex::new(Terminal::new(crate::crossterm::cursor::position()?)),
contents: SkipMap::new(),
layout_engine: Mutex::new(RendererLayout::default()),
layout: RwLock::new(None),
last_terminal_size: Mutex::new(None),
})
}
pub async fn try_new_with_graphemes<I, G>(init: I, draw: bool) -> anyhow::Result<Self>
where
I: IntoIterator<Item = (K, G)>,
G: Into<CreatedGraphemes>,
{
let renderer = Self::try_new()?;
renderer.update(init);
if draw {
renderer.render().await?;
}
Ok(renderer)
}
pub fn update<I, G>(&self, items: I) -> &Self
where
I: IntoIterator<Item = (K, G)>,
G: Into<CreatedGraphemes>,
{
items.into_iter().for_each(|(index, graphemes)| {
self.contents.insert(index, graphemes.into());
});
self
}
pub fn remove<I>(&self, items: I) -> &Self
where
I: IntoIterator<Item = K>,
{
let mut layout_engine = self.layout_engine.lock().expect("layout lock poisoned");
items.into_iter().for_each(|index| {
self.contents.remove(&index);
layout_engine.remove(&index);
});
self
}
pub fn hit_test(&self, position: ScreenPosition) -> Option<WidgetPosition<K>> {
let layout = self.layout.read().ok()?;
let layout = layout.as_ref()?;
if position.column >= layout.terminal_width {
return None;
}
let entry = layout.entries.iter().find(|entry| {
let start = entry.viewport.screen_row;
let end = start.saturating_add(entry.viewport.height);
position.row >= start && position.row < end
})?;
let relative_row = position.row.saturating_sub(entry.viewport.screen_row) as usize;
let visual_row = entry.viewport.content_row.saturating_add(relative_row);
let row = entry.rows.get(visual_row)?;
let screen_column = if position.row == layout.origin.row {
position.column.checked_sub(layout.origin.column)?
} else {
position.column
};
Some(WidgetPosition {
index: entry.index.clone(),
row: row.content_row,
column: row.content_column.saturating_add(screen_column as usize),
})
}
pub fn screen_position(&self, position: WidgetPosition<K>) -> Option<ScreenPosition> {
let layout = self.layout.read().ok()?;
let layout = layout.as_ref()?;
let entry = layout
.entries
.iter()
.find(|entry| entry.index == position.index)?;
let matching_rows = entry
.rows
.iter()
.enumerate()
.filter(|(_, row)| row.content_row == position.row)
.collect::<Vec<_>>();
let (visual_row, row) = matching_rows
.iter()
.copied()
.find(|(_, row)| {
let end = row.content_column.saturating_add(row.graphemes.widths());
position.column >= row.content_column && position.column < end
})
.or_else(|| matching_rows.last().copied())?;
let viewport_end = entry
.viewport
.content_row
.saturating_add(entry.viewport.height as usize);
if visual_row < entry.viewport.content_row || visual_row >= viewport_end {
return None;
}
let row_offset = visual_row.saturating_sub(entry.viewport.content_row) as u16;
let screen_row = entry.viewport.screen_row.saturating_add(row_offset);
let row_origin_column = if screen_row == layout.origin.row {
layout.origin.column
} else {
0
};
let column_offset = position.column.saturating_sub(row.content_column);
let column_offset = u16::try_from(column_offset).ok()?;
let column = row_origin_column.saturating_add(column_offset);
(column < layout.terminal_width).then_some(ScreenPosition {
row: screen_row,
column,
})
}
pub async fn render(&self) -> anyhow::Result<()> {
let contents = self
.contents
.iter()
.map(|entry| (entry.key().clone(), entry.value().clone()))
.collect::<Vec<_>>();
let mut terminal = self.terminal.lock().await;
let mut size = crate::crossterm::terminal::size()?;
let previous_size = *self
.last_terminal_size
.lock()
.expect("terminal size lock poisoned");
if previous_size.is_some_and(|previous| previous != size) {
size = wait_for_terminal_size_stability(size).await?;
}
loop {
let (terminal_width, terminal_height) = size;
let prepared = self
.layout_engine
.lock()
.expect("layout lock poisoned")
.layout(contents.iter().cloned(), terminal_width, terminal_height)?;
let panes = prepared.panes();
terminal.draw_rows_at_height(&panes, terminal_height)?;
drop(panes);
let current_size = crate::crossterm::terminal::size()?;
if current_size != size {
size = wait_for_terminal_size_stability(current_size).await?;
continue;
}
let origin = ScreenPosition {
row: terminal.position.1,
column: terminal.position.0,
};
*self.layout.write().expect("layout lock poisoned") =
Some(prepared.into_snapshot(origin));
*self
.last_terminal_size
.lock()
.expect("terminal size lock poisoned") = Some(size);
return Ok(());
}
}
}
async fn wait_for_terminal_size_stability(mut previous: (u16, u16)) -> std::io::Result<(u16, u16)> {
let mut stable_since = Instant::now();
loop {
tokio::time::sleep(RESIZE_SIZE_POLL).await;
let current = crate::crossterm::terminal::size()?;
if current != previous {
previous = current;
stable_since = Instant::now();
} else if stable_since.elapsed() >= RESIZE_SIZE_STABILITY {
return Ok(current);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{grapheme::StyledGraphemes, widget::WidgetViewport};
mod hit_test {
use super::*;
#[test]
fn maps_screen_positions_back_to_widget_positions() {
let renderer = Renderer {
terminal: AsyncMutex::new(Terminal::new((0, 0))),
contents: SkipMap::new(),
layout_engine: Mutex::new(RendererLayout::default()),
layout: RwLock::new(Some(LayoutSnapshot {
origin: ScreenPosition { row: 3, column: 0 },
terminal_width: 20,
entries: vec![layout::LayoutEntry {
index: 7usize,
viewport: WidgetViewport {
screen_row: 3,
height: 2,
content_row: 1,
},
rows: vec![
layout::VisualRow {
content_row: 0,
content_column: 0,
graphemes: StyledGraphemes::from("hidden"),
},
layout::VisualRow {
content_row: 1,
content_column: 0,
graphemes: StyledGraphemes::from("first"),
},
layout::VisualRow {
content_row: 2,
content_column: 0,
graphemes: StyledGraphemes::from("second"),
},
],
}],
})),
last_terminal_size: Mutex::new(None),
};
let screen = ScreenPosition { row: 4, column: 2 };
let widget = renderer.hit_test(screen).unwrap();
assert_eq!(
widget,
WidgetPosition {
index: 7,
row: 2,
column: 2,
}
);
assert_eq!(renderer.screen_position(widget), Some(screen));
}
}
}