use crate::completed_spill::CompletedSpillArchive;
use crate::spill_view::{SpillStream, SpillView};
use crossterm::cursor::{MoveDown, MoveTo, MoveToColumn, MoveUp};
use crossterm::queue;
use crossterm::style::{Color, ContentStyle, Print, ResetColor, SetForegroundColor, StyledContent};
use crossterm::terminal::{Clear, ClearType};
use newt_core::{LiveToolOutput, ToolOutputStream};
use newt_core::agentic::{write_file_change_row, CompletedSpillRenderer, FileChangePresentation};
use newt_core::tty::{blank_rows, OnCollision, Placement, RegionLease, Terminal};
use std::io::Write;
#[cfg(any(unix, test))]
use std::sync::atomic::AtomicBool;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, TryLockError};
const HISTORY_LINES: usize = 4_096;
const LINE_CHARS: usize = 4_096;
struct RenderState {
geometry: Box<dyn Fn() -> Option<(usize, usize)> + Send + Sync>,
view: Option<SpillView>,
file_change: Option<RichFileChange>,
columns: usize,
terminal_rows: usize,
collapsed_rows: usize,
max_rows: usize,
desired_rows: usize,
drawable: bool,
color: bool,
generation: Option<u64>,
}
struct RichFileChange {
change: Arc<FileChangePresentation>,
prefix: String,
suffix: String,
rows: Vec<Vec<StyledContent<String>>>,
}
impl RichFileChange {
fn reproject(&mut self, columns: usize) -> String {
self.rows = self
.change
.display_rows(&self.prefix, &self.suffix, columns.saturating_sub(3));
self.rows
.iter()
.map(|row| {
row.iter()
.map(|run| run.content().as_str())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
}
struct Frame {
_lease: Option<RegionLease>,
place: Placement,
columns: usize,
terminal_rows: usize,
}
type Placer = Box<dyn FnMut(u16) -> Option<(Option<RegionLease>, Placement)> + Send>;
fn arbiter_placer() -> Placer {
Box::new(|height| {
Terminal::lease_below_cursor_quiet(height, OnCollision::Shift)
.ok()
.flatten()
.map(|(lease, place)| (Some(lease), place))
})
}
struct OutputState {
writer: TerminalWriter,
painted_lines: Vec<String>,
painted_generation: Option<u64>,
placement_retry: Option<u64>,
frame: Option<Frame>,
placer: Placer,
registration: Option<newt_core::tty::EphemeralRegistration>,
}
enum TerminalWriter {
Stdout,
#[cfg(test)]
Other(Box<dyn Write + Send>),
}
impl TerminalWriter {
fn write_batch(
&mut self,
bytes: &[u8],
still_valid: impl FnOnce() -> bool,
) -> std::io::Result<bool> {
match self {
Self::Stdout => {
let stdout = std::io::stdout();
let mut stdout = stdout.lock();
if !still_valid() {
return Ok(false);
}
stdout.write_all(bytes)?;
stdout.flush()?;
}
#[cfg(test)]
Self::Other(writer) => {
if !still_valid() {
return Ok(false);
}
writer.write_all(bytes)?;
writer.flush()?;
}
}
Ok(true)
}
}
pub(crate) struct LiveSpillRenderer {
state: Arc<Mutex<RenderState>>,
output: Arc<Mutex<OutputState>>,
abandoned_through: Arc<AtomicU64>,
completed_archive: Option<Arc<CompletedSpillArchive>>,
#[cfg(any(unix, test))]
repaint_requested: Arc<AtomicU64>,
#[cfg(any(unix, test))]
repaint_running: Arc<AtomicBool>,
}
impl LiveSpillRenderer {
pub(crate) fn stdout(
rows: usize,
color: bool,
completed_archive: Arc<CompletedSpillArchive>,
) -> Option<Arc<Self>> {
let me = Arc::new(Self::with_output_and_geometry(
TerminalWriter::Stdout,
arbiter_placer(),
rows,
color,
Some(completed_archive),
|| {
crossterm::terminal::size()
.ok()
.map(|(columns, rows)| (usize::from(columns), usize::from(rows)))
},
)?);
me.register_with_arbiter();
Some(me)
}
fn register_with_arbiter(self: &Arc<Self>) {
let ephemeral: Arc<dyn newt_core::tty::Ephemeral> = self.clone();
let registration = newt_core::tty::Terminal::register_ephemeral(&ephemeral);
self.output
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.registration = Some(registration);
}
#[cfg(test)]
fn register_for_test(self: &Arc<Self>) {
self.register_with_arbiter();
}
#[cfg(test)]
fn with_writer(
writer: impl Write + Send + 'static,
placer: Placer,
columns: usize,
rows: usize,
color: bool,
) -> Self {
Self::with_writer_and_geometry(writer, placer, rows, color, move || {
Some((columns, rows.saturating_add(3)))
})
.expect("fixed test geometry is drawable")
}
#[cfg(test)]
fn with_writer_and_geometry(
writer: impl Write + Send + 'static,
placer: Placer,
desired_rows: usize,
color: bool,
geometry: impl Fn() -> Option<(usize, usize)> + Send + Sync + 'static,
) -> Option<Self> {
Self::with_output_and_geometry(
TerminalWriter::Other(Box::new(writer)),
placer,
desired_rows,
color,
None,
geometry,
)
}
fn with_output_and_geometry(
writer: TerminalWriter,
placer: Placer,
desired_rows: usize,
color: bool,
completed_archive: Option<Arc<CompletedSpillArchive>>,
geometry: impl Fn() -> Option<(usize, usize)> + Send + Sync + 'static,
) -> Option<Self> {
let (columns, terminal_rows) = geometry()?;
let (collapsed_rows, max_rows) = viewport_geometry(desired_rows, columns, terminal_rows)?;
Some(Self {
state: Arc::new(Mutex::new(RenderState {
geometry: Box::new(geometry),
view: None,
file_change: None,
columns,
terminal_rows,
collapsed_rows,
max_rows,
desired_rows,
drawable: true,
color,
generation: None,
})),
output: Arc::new(Mutex::new(OutputState {
writer,
painted_lines: Vec::new(),
painted_generation: None,
placement_retry: None,
frame: None,
placer,
registration: None,
})),
abandoned_through: Arc::new(AtomicU64::new(0)),
completed_archive,
#[cfg(any(unix, test))]
repaint_requested: Arc::new(AtomicU64::new(0)),
#[cfg(any(unix, test))]
repaint_running: Arc::new(AtomicBool::new(false)),
})
}
#[cfg(any(unix, test))]
pub(crate) fn scroll_up(&self) -> bool {
self.scroll(SpillView::scroll_up)
}
#[cfg(any(unix, test))]
pub(crate) fn scroll_down(&self) -> bool {
self.scroll(SpillView::scroll_down)
}
#[cfg(any(unix, test))]
pub(crate) fn toggle_expanded(&self) -> bool {
self.scroll(SpillView::toggle_expanded)
}
#[cfg(unix)]
#[allow(dead_code)]
pub(crate) fn expand_half(&self) -> bool {
self.scroll(SpillView::expand_half)
}
#[cfg(unix)]
#[allow(dead_code)]
pub(crate) fn is_exploring(&self) -> bool {
let state = self.lock_state();
state
.view
.as_ref()
.is_some_and(|view| !view.is_following_tail())
}
#[cfg(unix)]
#[allow(dead_code)]
pub(crate) fn exit_explore(&self) -> bool {
self.scroll(SpillView::scroll_to_bottom)
}
#[cfg(unix)]
pub(crate) fn scroll_to_top(&self) -> bool {
self.scroll(SpillView::scroll_to_top)
}
#[cfg(unix)]
pub(crate) fn scroll_to_bottom(&self) -> bool {
self.scroll(SpillView::scroll_to_bottom)
}
#[cfg(unix)]
pub(crate) fn half_page_up(&self) -> bool {
self.scroll(SpillView::half_page_up)
}
#[cfg(unix)]
pub(crate) fn half_page_down(&self) -> bool {
self.scroll(SpillView::half_page_down)
}
#[cfg(unix)]
pub(crate) fn refresh_geometry(&self) -> bool {
let Some(mut state) = self.try_lock_state() else {
return false;
};
if state.view.is_none() {
return false;
}
let before = (
state.columns,
state.terminal_rows,
state.collapsed_rows,
state.max_rows,
state.drawable,
);
let _ = sync_geometry(&mut state);
let changed = before
!= (
state.columns,
state.terminal_rows,
state.collapsed_rows,
state.max_rows,
state.drawable,
);
let generation = state.generation;
let drawable = state.drawable;
drop(state);
let retry = drawable
&& generation.is_some_and(|generation| !self.is_abandoned(generation))
&& self.output.try_lock().is_ok_and(|output| {
output.placement_retry == generation
&& !output
.registration
.as_ref()
.is_some_and(newt_core::tty::EphemeralRegistration::suspended)
});
if changed || retry {
self.repaint_async();
}
true
}
#[cfg(any(unix, test))]
fn scroll(&self, action: fn(&mut SpillView)) -> bool {
let mut state = self.lock_state();
let Some(view) = state.view.as_mut() else {
return false;
};
action(view);
drop(state);
self.repaint_async();
true
}
#[cfg(any(unix, test))]
fn repaint_async(&self) {
self.repaint_requested.fetch_add(1, Ordering::Release);
if self
.repaint_running
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_err()
{
return;
}
let state = self.state.clone();
let output = self.output.clone();
let abandoned_through = self.abandoned_through.clone();
let repaint_requested = self.repaint_requested.clone();
let repaint_running = self.repaint_running.clone();
if std::thread::Builder::new()
.name("newt-live-spill-input".to_string())
.spawn(move || {
run_input_repaint(
&state,
&output,
&abandoned_through,
&repaint_requested,
&repaint_running,
);
})
.is_err()
{
self.repaint_running.store(false, Ordering::Release);
}
}
#[cfg(test)]
pub(crate) fn is_active(&self) -> bool {
self.lock_state().view.is_some()
}
#[cfg(test)]
fn snapshot_lines(&self) -> Vec<String> {
let state = self.lock_state();
state
.view
.as_ref()
.map(|view| fixed_frame_lines(view, state.generation == Some(COMPLETED_GENERATION)))
.unwrap_or_default()
}
fn lock_state(&self) -> std::sync::MutexGuard<'_, RenderState> {
self.state
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
fn try_lock_state(&self) -> Option<std::sync::MutexGuard<'_, RenderState>> {
match self.state.try_lock() {
Ok(state) => Some(state),
Err(TryLockError::Poisoned(poisoned)) => Some(poisoned.into_inner()),
Err(TryLockError::WouldBlock) => None,
}
}
fn is_abandoned(&self, generation: u64) -> bool {
generation <= self.abandoned_through.load(Ordering::Acquire)
}
}
impl newt_core::tty::Ephemeral for LiveSpillRenderer {
fn erase(&self) {
let (columns, terminal_rows) = {
let mut state = self.lock_state();
let _ = sync_geometry(&mut state);
(state.columns, state.terminal_rows)
};
let mut output = self
.output
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if let Some(generation) = output.painted_generation {
erase_output(
&mut output,
columns,
terminal_rows,
&self.abandoned_through,
generation,
);
}
}
fn restore(&self) {
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let generation = self.lock_state().generation;
if let Some(generation) = generation {
paint_generation(
&self.state,
&self.output,
&self.abandoned_through,
generation,
);
}
}));
}
}
impl LiveToolOutput for LiveSpillRenderer {
fn start(&self, generation: u64) {
if self.is_abandoned(generation) {
return;
}
let mut state = self.lock_state();
let _ = sync_geometry(&mut state);
let mut view = SpillView::with_limits(
state.columns,
state.collapsed_rows,
HISTORY_LINES,
LINE_CHARS,
);
view.resize(state.columns, state.collapsed_rows, state.max_rows);
state.view = Some(view);
state.file_change = None;
state.generation = Some(generation);
}
fn write(&self, generation: u64, stream: ToolOutputStream, chunk: &[u8]) {
if chunk.is_empty() {
return;
}
if self.is_abandoned(generation) {
return;
}
let mut state = self.lock_state();
if state.generation != Some(generation) {
return;
}
let Some(view) = state.view.as_mut() else {
return;
};
let stream = match stream {
ToolOutputStream::Stdout => SpillStream::Stdout,
ToolOutputStream::Stderr => SpillStream::Stderr,
};
view.push_stream_bytes(stream, chunk);
let _ = sync_geometry(&mut state);
drop(state);
paint_generation(
&self.state,
&self.output,
&self.abandoned_through,
generation,
);
}
fn finish(&self, generation: u64) {
if self.is_abandoned(generation) {
return;
}
let mut state = self.lock_state();
if state.generation != Some(generation) {
return;
}
if let Some(view) = state.view.as_mut() {
view.finish();
}
let _ = sync_geometry(&mut state);
let columns = state.columns;
let terminal_rows = state.terminal_rows;
state.view = None;
state.file_change = None;
state.generation = None;
drop(state);
erase_generation(
&self.output,
&self.abandoned_through,
generation,
columns,
terminal_rows,
);
}
fn abandon(&self, generation: u64) {
self.abandoned_through
.fetch_max(generation, Ordering::AcqRel);
if let Some(mut state) = self.try_lock_state() {
if state.generation == Some(generation) {
state.view = None;
state.file_change = None;
state.generation = None;
}
}
}
}
fn fixed_frame_lines(view: &SpillView, completed: bool) -> Vec<String> {
let frame = if completed {
view.completed_frame()
} else {
view.frame()
};
let rows = view.visible_rows();
let mut lines = Vec::with_capacity(rows + 2);
lines.push(frame.top.line);
lines.extend(frame.content.into_iter().map(|row| row.line));
while lines.len() < rows + 1 {
lines.push(if completed { "⎴" } else { "▒" }.to_string());
}
lines.push(frame.bottom.line);
lines
}
fn fixed_file_change_styles(
view: &SpillView,
change: &RichFileChange,
) -> Vec<Vec<StyledContent<String>>> {
let frame = view.completed_frame();
let dim = ContentStyle {
foreground_color: Some(
newt_core::tty::theme::active().color(newt_core::tty::theme::Role::Dim),
),
..ContentStyle::default()
};
let plain = |text: String| vec![dim.apply(text)];
let mut lines = vec![plain(frame.top.line)];
for row in frame.content {
let source = row.source_line.and_then(|index| change.rows.get(index));
let gutter = format!("{} ", row.gutter.glyph());
let Some(source) = source.filter(|source| {
row.line == format!("{gutter}{}", row.text)
&& source
.iter()
.map(|run| run.content().as_str())
.collect::<String>()
.starts_with(&row.text)
}) else {
lines.push(plain(row.line));
continue;
};
let mut remaining = row.text.chars().count();
let mut styled = plain(gutter);
for run in source {
if remaining == 0 {
break;
}
let text: String = run.content().chars().take(remaining).collect();
remaining -= text.chars().count();
styled.push(run.style().apply(text));
}
lines.push(styled);
}
while lines.len() < view.visible_rows() + 1 {
lines.push(plain("⎴".into()));
}
lines.push(plain(frame.bottom.line));
lines
}
fn viewport_geometry(
desired_rows: usize,
columns: usize,
terminal_rows: usize,
) -> Option<(usize, usize)> {
(desired_rows > 0 && columns >= 2 && terminal_rows >= 4).then(|| {
let max_rows = terminal_rows - 3;
(desired_rows.min(max_rows), max_rows)
})
}
fn sync_geometry(state: &mut RenderState) -> bool {
let Some((columns, terminal_rows)) = (state.geometry)() else {
state.drawable = false;
return false;
};
state.terminal_rows = terminal_rows;
let Some((collapsed_rows, max_rows)) =
viewport_geometry(state.desired_rows, columns, terminal_rows)
else {
state.columns = columns;
state.drawable = false;
return false;
};
if !state.drawable
|| state.columns != columns
|| state.collapsed_rows != collapsed_rows
|| state.max_rows != max_rows
{
state.columns = columns;
state.collapsed_rows = collapsed_rows;
state.max_rows = max_rows;
let replacement = state
.file_change
.as_mut()
.map(|change| change.reproject(columns));
if let Some(view) = state.view.as_mut() {
view.resize(columns, collapsed_rows, max_rows);
if let Some(text) = replacement {
view.replace_completed_text(&text);
}
}
}
state.drawable = true;
true
}
fn paint_generation(
state: &Mutex<RenderState>,
output: &Mutex<OutputState>,
abandoned_through: &AtomicU64,
generation: u64,
) {
if is_abandoned(abandoned_through, generation) {
return;
}
let mut output = output
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if output
.registration
.as_ref()
.is_some_and(newt_core::tty::EphemeralRegistration::suspended)
{
return;
}
if is_abandoned(abandoned_through, generation) {
discard_generation(&mut output, generation);
return;
}
let snapshot = {
let state = state
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
(state.generation == Some(generation)).then(|| {
(
state.drawable.then(|| {
state
.view
.as_ref()
.map(|view| fixed_frame_lines(view, generation == COMPLETED_GENERATION))
.unwrap_or_default()
}),
state.color,
state.columns,
state.terminal_rows,
state
.file_change
.as_ref()
.zip(state.view.as_ref())
.map(|(change, view)| fixed_file_change_styles(view, change)),
)
})
};
let Some((lines, color, columns, terminal_rows, styles)) = snapshot else {
return;
};
if is_abandoned(abandoned_through, generation) {
discard_generation(&mut output, generation);
return;
}
let height = u16::try_from(lines.as_ref().map_or(0, Vec::len)).unwrap_or(u16::MAX);
let wanted = height.saturating_add(1);
let replacing =
output.painted_generation == Some(generation) || output.placement_retry == Some(generation);
let in_place = match (&output.frame, output.painted_generation) {
(Some(frame), Some(previous)) => {
previous == generation
&& !is_abandoned(abandoned_through, previous)
&& frame.place.height == wanted
&& frame.columns == columns
&& frame.terminal_rows == terminal_rows
}
_ => false,
};
if !in_place {
if let Some(previous) = output.painted_generation {
if is_abandoned(abandoned_through, previous) {
discard_generation(&mut output, previous);
} else {
erase_output(
&mut output,
columns,
terminal_rows,
abandoned_through,
previous,
);
}
}
}
let Some(lines) = lines else {
return;
};
let (lease, place) = if in_place {
let frame = output.frame.take().expect("checked above");
(frame._lease, frame.place)
} else {
let Some(placed) = (output.placer)(wanted) else {
output.placement_retry = replacing.then_some(generation);
return;
};
placed
};
if place.height < wanted {
output.placement_retry = None;
return;
}
let mut batch = Vec::new();
if !in_place && place.scroll > 0 {
let _ = queue!(&mut batch, MoveTo(0, place.rows.saturating_sub(1)));
for _ in 0..place.scroll {
let _ = queue!(&mut batch, Print("\n"));
}
}
if color {
let _ = queue!(&mut batch, SetForegroundColor(Color::DarkGrey));
}
for (index, line) in lines.iter().enumerate() {
let row = place
.top
.saturating_add(u16::try_from(index).unwrap_or(u16::MAX));
let _ = queue!(&mut batch, MoveTo(0, row), Clear(ClearType::CurrentLine));
if let Some(styled) = styles
.as_ref()
.filter(|_| color)
.and_then(|rows| rows.get(index))
{
let _ = write_file_change_row(&mut batch, styled);
} else {
let _ = queue!(&mut batch, Print(line));
}
}
if color {
let _ = queue!(&mut batch, ResetColor);
}
let _ = queue!(&mut batch, MoveTo(0, place.top.saturating_add(height)));
let wrote = output
.writer
.write_batch(&batch, || !is_abandoned(abandoned_through, generation))
.unwrap_or(false);
if !wrote || is_abandoned(abandoned_through, generation) {
discard_generation(&mut output, generation);
return;
}
output.placement_retry = None;
output.painted_lines.clone_from(&lines);
output.painted_generation = Some(generation);
output.frame = Some(Frame {
_lease: lease,
place,
columns,
terminal_rows,
});
}
#[cfg(any(unix, test))]
fn run_input_repaint(
state: &Mutex<RenderState>,
output: &Mutex<OutputState>,
abandoned_through: &AtomicU64,
repaint_requested: &AtomicU64,
repaint_running: &AtomicBool,
) {
loop {
let observed = repaint_requested.load(Ordering::Acquire);
let generation = state
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.generation;
if let Some(generation) = generation {
paint_generation(state, output, abandoned_through, generation);
}
if repaint_requested.load(Ordering::Acquire) != observed {
continue;
}
repaint_running.store(false, Ordering::Release);
if repaint_requested.load(Ordering::Acquire) == observed
|| repaint_running
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_err()
{
break;
}
}
}
#[allow(dead_code)]
fn erase_generation(
output: &Mutex<OutputState>,
abandoned_through: &AtomicU64,
generation: u64,
columns: usize,
terminal_rows: usize,
) {
let mut output = output
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if is_abandoned(abandoned_through, generation) {
discard_generation(&mut output, generation);
return;
}
if output.placement_retry == Some(generation) {
output.placement_retry = None;
}
if output.painted_generation == Some(generation) {
erase_output(
&mut output,
columns,
terminal_rows,
abandoned_through,
generation,
);
}
}
#[allow(dead_code)]
fn erase_output(
output: &mut OutputState,
columns: usize,
terminal_rows: usize,
abandoned_through: &AtomicU64,
generation: u64,
) {
let Some(frame) = output.frame.take() else {
output.painted_lines.clear();
output.painted_generation = None;
return;
};
if output.painted_lines.is_empty() {
output.painted_generation = None;
return;
}
let mut batch = Vec::new();
if frame.columns == columns && frame.terminal_rows == terminal_rows {
let _ = blank_rows(&mut batch, frame.place.top, frame.place.height);
let (x, y) = frame.place.return_to;
let _ = queue!(&mut batch, MoveTo(x, y));
} else if frame.columns == columns {
let height = frame.place.height;
let visible = height.min(u16::try_from(terminal_rows).unwrap_or(u16::MAX).max(1));
if height > 1 {
let _ = queue!(&mut batch, MoveUp(height - 1));
}
let _ = queue!(&mut batch, MoveToColumn(0));
for row in 0..visible {
let _ = queue!(&mut batch, Clear(ClearType::CurrentLine));
if row + 1 < visible {
let _ = queue!(&mut batch, MoveDown(1));
}
}
let parked = frame.place.top.saturating_add(height.saturating_sub(1));
let (x, y) = frame.place.return_to;
if parked > y {
let _ = queue!(&mut batch, MoveUp(parked - y));
} else if y > parked {
let _ = queue!(&mut batch, MoveDown(y - parked));
}
let _ = queue!(&mut batch, MoveToColumn(x));
} else {
let physical_rows = output
.painted_lines
.iter()
.map(|line| physical_rows(line, columns))
.sum::<usize>();
let _ = queue!(
&mut batch,
MoveUp(u16::try_from(physical_rows).unwrap_or(u16::MAX)),
MoveToColumn(0),
Clear(ClearType::FromCursorDown)
);
}
let _ = output
.writer
.write_batch(&batch, || !is_abandoned(abandoned_through, generation));
output.painted_lines.clear();
output.painted_generation = None;
}
fn discard_generation(output: &mut OutputState, generation: u64) {
if output.placement_retry == Some(generation) {
output.placement_retry = None;
}
if output.painted_generation == Some(generation) {
output.painted_lines.clear();
output.painted_generation = None;
output.frame = None;
}
}
fn is_abandoned(abandoned_through: &AtomicU64, generation: u64) -> bool {
generation <= abandoned_through.load(Ordering::Acquire)
}
fn char_cells(ch: char) -> usize {
if matches!(
ch,
'\u{0300}'..='\u{036f}'
| '\u{1ab0}'..='\u{1aff}'
| '\u{1dc0}'..='\u{1dff}'
| '\u{20d0}'..='\u{20ff}'
| '\u{fe00}'..='\u{fe0f}'
| '\u{fe20}'..='\u{fe2f}'
| '\u{e0100}'..='\u{e01ef}'
) {
0
} else if ch.is_ascii() || matches!(ch, '…' | '▲' | '▼' | '▒' | '▓' | '⧉' | '▣' | '\u{fffd}')
{
1
} else {
2
}
}
#[allow(dead_code)]
fn rendered_width(text: &str) -> usize {
text.chars().map(char_cells).sum()
}
fn physical_rows(text: &str, columns: usize) -> usize {
let columns = columns.max(1);
let mut rows = 1usize;
let mut used = 0usize;
for cells in text.chars().map(char_cells) {
if cells > 0 && used > 0 && used + cells > columns {
rows += 1;
used = 0;
}
used += cells;
}
rows
}
const COMPLETED_GENERATION: u64 = u64::MAX;
impl LiveSpillRenderer {
fn show_completed(
&self,
output: &str,
width: usize,
max_height: usize,
mut change: Option<RichFileChange>,
) -> usize {
{
let mut state = self.lock_state();
if state
.generation
.is_some_and(|generation| generation != COMPLETED_GENERATION)
{
return 0;
}
if !sync_geometry(&mut state) {
return 0;
}
let mut view =
SpillView::with_limits(state.columns, max_height.max(1), HISTORY_LINES, LINE_CHARS);
let rich_text = change
.as_mut()
.map(|change| change.reproject(state.columns));
view.push_stream_bytes(
SpillStream::Stdout,
rich_text.as_deref().unwrap_or(output).as_bytes(),
);
view.finish();
let (_, terminal_rows) = (state.geometry)().unwrap_or((width, 24));
let max_allowed = (terminal_rows / 2).max(3).min(state.max_rows.max(1));
let rows_to_show = view
.retained_line_count()
.clamp(1, max_allowed.min(max_height.max(1)));
view.resize(state.columns, rows_to_show, max_allowed);
state.view = Some(view);
state.file_change = change;
state.generation = Some(COMPLETED_GENERATION);
}
paint_generation(
&self.state,
&self.output,
&self.abandoned_through,
COMPLETED_GENERATION,
);
let columns = self.lock_state().columns.max(1);
let output_state = self
.output
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if output_state.painted_generation != Some(COMPLETED_GENERATION) {
return 0;
}
output_state
.painted_lines
.iter()
.map(|line| physical_rows(line, columns))
.sum()
}
}
impl CompletedSpillRenderer for LiveSpillRenderer {
fn retain_completed(&self, output: &str) -> Option<u64> {
self.completed_archive
.as_ref()
.map(|archive| archive.retain(output))
}
fn render_completed(&self, output: &str, width: usize, max_height: usize) -> usize {
self.show_completed(output, width, max_height, None)
}
fn render_file_change(
&self,
raw_output: &str,
output: &str,
change: Arc<FileChangePresentation>,
width: usize,
max_height: usize,
) -> usize {
let surroundings = self
.lock_state()
.color
.then(|| change.surrounding_display_text(raw_output, output))
.flatten();
let Some((prefix, suffix)) = surroundings else {
return self.render_completed(output, width, max_height);
};
self.show_completed(
output,
width,
max_height,
Some(RichFileChange {
change,
prefix,
suffix,
rows: Vec::new(),
}),
)
}
fn is_active(&self) -> bool {
self.lock_state().generation == Some(COMPLETED_GENERATION)
}
fn discard(&self) {
{
let mut state = self.lock_state();
if state.generation != Some(COMPLETED_GENERATION) {
return;
}
state.view = None;
state.file_change = None;
state.generation = None;
}
let mut output = self
.output
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
discard_generation(&mut output, COMPLETED_GENERATION);
}
fn erase(&self) {
let (columns, terminal_rows) = {
let mut state = self.lock_state();
if state.generation != Some(COMPLETED_GENERATION) {
return;
}
let _ = sync_geometry(&mut state);
state.view = None;
state.file_change = None;
state.generation = None;
(state.columns, state.terminal_rows)
};
erase_generation(
&self.output,
&self.abandoned_through,
COMPLETED_GENERATION,
columns,
terminal_rows,
);
}
}
#[cfg(test)]
#[path = "live_spill_tests.rs"]
mod tests;