From 54da1d578b6528d62e7fc3ea5f708d266bb9e1b1 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Tue, 11 Aug 2026 15:50:44 +0800 Subject: [PATCH] fix(tui): bound terminal frame symbols Closes #87 --- CHANGELOG.md | 5 + README.md | 1 + docs/tut.1 | 5 +- src/app.rs | 64 ++++++++++ src/error.rs | 6 + src/tui/mod.rs | 326 +++++++++++++++++++++++++++++++++++++++++++++--- src/tui/view.rs | 277 +++++++++++++++++++++++++++++++--------- 7 files changed, 606 insertions(+), 78 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 726691d..a2aed58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ tag does not describe the present program or its compatibility. ## Unreleased +### Fixed + +- Heap-backed terminal cell symbols now share the 64 MiB terminal-buffer + accounting budget and over-budget frames are rejected before publication. + ## 0.0.5 - 2026-08-11 ### Added diff --git a/README.md b/README.md index e83e327..3d7a17d 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,7 @@ from search input closes only with Esc or `F1`. | Oversized graphemes | A grapheme over 1024 UTF-8 bytes is replaced safely | | Opened file mutation | Fingerprint changes are reported as an error | | Minimum terminal | 16 columns by 4 rows for the reader view | +| Terminal frames | Two cell buffers and heap symbols share a 64 MiB budget | | Tested systems | Linux and macOS | Windows is not supported. Other Unix systems may work, but are not currently diff --git a/docs/tut.1 b/docs/tut.1 index eee9e94..f15c2db 100644 --- a/docs/tut.1 +++ b/docs/tut.1 @@ -181,8 +181,9 @@ The complete reader requires a terminal of at least 16 columns by 4 rows. Smaller terminals display resize and quit guidance. .IP \(bu 2 Terminal geometry is limited to 524,288 cells. -The two fixed cell vectors are also subject to a 64 MiB accounting budget; -this is not a limit on total process memory or on heap-backed cell symbols. +The two fixed cell vectors and the heap-backed cell symbols in both frames +share a conservative 64 MiB accounting budget. +This is not a limit on total process memory. .IP \(bu 2 Visible rendered content is limited to 32 MiB. .IP \(bu 2 diff --git a/src/app.rs b/src/app.rs index beae65b..9623454 100644 --- a/src/app.rs +++ b/src/app.rs @@ -463,6 +463,65 @@ impl RenderRows { self.rows.as_ptr(), ) } + + #[cfg(test)] + pub(super) fn storage_bytes(&self) -> usize { + self.storage() + .bytes() + .expect("render-row storage capacities fit usize") + } + + #[cfg(test)] + pub(super) fn from_prebuilt_runs( + run: &str, + count: usize, + cell_width: u16, + ) -> Result { + let text_bytes = run + .len() + .checked_mul(count) + .ok_or(TutError::Allocation("test render rows"))?; + let mut text = String::new(); + text.try_reserve_exact(text_bytes) + .map_err(|_| TutError::Allocation("test render rows"))?; + let mut spans = Vec::new(); + spans + .try_reserve_exact(count) + .map_err(|_| TutError::Allocation("test render rows"))?; + let mut rows = Vec::new(); + rows.try_reserve_exact(count) + .map_err(|_| TutError::Allocation("test render rows"))?; + + for _ in 0..count { + let start = text.len(); + text.push_str(run); + let end = text.len(); + let span = spans.len(); + spans.push(RenderSpan { + text: RenderTextRange::new(start, end), + source: GraphemeRange::new( + SourceOffset::from_usize(start), + SourceOffset::from_usize(end), + ) + .expect("prebuilt test runs are nonempty"), + projection: RenderProjectionKind::Text, + cell_width: DisplayColumn::new(u32::from(cell_width)), + }); + rows.push(RenderRowRange { + text: start..end, + spans: span..span + 1, + }); + } + + let rows = Self { + text, + spans, + rows, + reserve_attempts: RenderReserveAttempts::ZERO, + }; + rows.storage().require(MAX_VISIBLE_RENDER_BYTES)?; + Ok(rows) + } } #[derive(Debug, Clone, Copy)] @@ -490,6 +549,11 @@ impl<'a> RenderRowsView<'a> { Self::new(&EMPTY_RENDER_ROWS, &[], None) } + #[cfg(test)] + pub(super) const fn from_rows(rows: &'a RenderRows) -> Self { + Self::new(rows, &[], None) + } + pub(super) fn iter(self) -> impl ExactSizeIterator> + 'a { self.rows.iter() } diff --git a/src/error.rs b/src/error.rs index b3d1526..d24f241 100644 --- a/src/error.rs +++ b/src/error.rs @@ -116,6 +116,9 @@ pub enum TutError { VisibleRenderTooLarge { limit: usize, }, + TerminalFrameSymbolBudgetExceeded { + limit: u64, + }, Layout(LayoutError), Search(SearchError), Log(LogError), @@ -201,6 +204,9 @@ impl TutError { Self::VisibleRenderTooLarge { limit } => { format!("visible rendered content exceeds the {limit}-byte limit") } + Self::TerminalFrameSymbolBudgetExceeded { limit } => { + format!("terminal frame symbols exceed the {limit}-byte limit") + } Self::Layout(LayoutError::DocumentMismatch) => { "layout state belongs to another document".to_owned() } diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 43feb9d..a857401 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -18,13 +18,16 @@ use crossterm::{ }, }; use ratatui::{ - Terminal, TerminalOptions, Viewport, backend::CrosstermBackend, buffer::Cell, layout::Rect, + Terminal, TerminalOptions, Viewport, + backend::{Backend, CrosstermBackend}, + buffer::Cell, + layout::Rect, }; use signals::SuspendOutcome; pub(super) use signals::{ProcessSessionLease, SignalHandlers, SignalState}; use crate::{ - app::{Action, App, BackgroundWork, Geometry, Outcome}, + app::{Action, App, BackgroundWork, Geometry, Outcome, ViewState}, error::{ExternalSignal, RunOutcome, TutError}, observer::{DisabledRecorder, Observer, RuntimeOperation, RuntimeRecorder}, }; @@ -33,6 +36,48 @@ const MAX_POLL: Duration = Duration::from_millis(100); const BACKGROUND_POLL: Duration = Duration::ZERO; const MAX_TERMINAL_CELLS: u64 = 512 * 1024; const MAX_TERMINAL_BUFFER_BYTES: u64 = 64 * 1024 * 1024; +const COMPACT_STRING_INLINE_BYTES: usize = 24; +const COMPACT_STRING_MIN_HEAP_BYTES: usize = size_of::() + size_of::(); + +const _: () = { + assert!(size_of::() == COMPACT_STRING_INLINE_BYTES); + assert!(COMPACT_STRING_MIN_HEAP_BYTES == 32); +}; + +#[derive(Debug)] +pub(super) struct FrameSymbolMeter { + used: u64, + limit: u64, +} + +impl FrameSymbolMeter { + const fn new(limit: u64) -> Self { + Self { used: 0, limit } + } + + fn charge(&mut self, symbol: &str) -> Result<(), TutError> { + let requested = if symbol.len() <= COMPACT_STRING_INLINE_BYTES { + 0 + } else { + symbol.len().max(COMPACT_STRING_MIN_HEAP_BYTES) + }; + let requested = u64::try_from(requested) + .map_err(|_| TutError::TerminalFrameSymbolBudgetExceeded { limit: self.limit })?; + let Some(next) = self.used.checked_add(requested) else { + return Err(TutError::TerminalFrameSymbolBudgetExceeded { limit: self.limit }); + }; + if next > self.limit { + return Err(TutError::TerminalFrameSymbolBudgetExceeded { limit: self.limit }); + } + self.used = next; + Ok(()) + } + + #[cfg(test)] + const fn used(&self) -> u64 { + self.used + } +} #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct TerminalSize { @@ -65,7 +110,6 @@ impl TerminalSize { Rect::new(0, 0, self.width, self.height) } - #[cfg(test)] fn buffer_bytes(self) -> u64 { u64::from(self.width) * u64::from(self.height) @@ -73,6 +117,10 @@ impl TerminalSize { * u64::try_from(size_of::()) .expect("terminal cell sizes fit unsigned 64-bit integers") } + + fn symbol_budget(self) -> u64 { + (MAX_TERMINAL_BUFFER_BYTES - self.buffer_bytes()) / 2 + } } fn terminal_cell_limit() -> u64 { @@ -594,6 +642,41 @@ struct CrosstermDriver { terminal: Terminal>, } +fn try_draw_fallible(terminal: &mut Terminal, render: F) -> Result<(), TutError> +where + B: Backend, + F: FnOnce(&mut ratatui::Frame<'_>) -> Result<(), TutError>, +{ + let mut render_error = None; + let result = terminal.try_draw(|frame| match render(frame) { + Ok(()) => Ok(()), + Err(error) => { + render_error = Some(error); + Err(io::Error::other("terminal frame rendering rejected")) + } + }); + + if let Some(error) = render_error { + terminal.current_buffer_mut().reset(); + return Err(error); + } + + result.map(|_| ()).map_err(|source| TutError::Io { + operation: "draw terminal frame", + source, + }) +} + +fn render_budgeted_frame( + frame: &mut ratatui::Frame<'_>, + state: &ViewState<'_>, +) -> Result<(), TutError> { + let area = frame.area(); + let size = TerminalSize::new(area.width, area.height)?; + let mut symbols = FrameSymbolMeter::new(size.symbol_budget()); + view::render(frame, state, &mut symbols) +} + impl CrosstermDriver { fn new() -> io::Result { let options = TerminalOptions { @@ -633,16 +716,9 @@ impl TerminalDriver for CrosstermDriver { fn draw(&mut self, app: &mut App) -> Result<(), TutError> { let state = app.view_state()?; - let mut view_result = Ok(()); - self.terminal - .draw(|frame| { - view_result = view::render(frame, &state); - }) - .map_err(|source| TutError::Io { - operation: "draw terminal frame", - source, - })?; - view_result + try_draw_fallible(&mut self.terminal, |frame| { + render_budgeted_frame(frame, &state) + }) } fn poll(&mut self, timeout: Duration) -> io::Result { @@ -706,15 +782,99 @@ pub(super) fn run( #[cfg(test)] mod tests { - use std::{cell::Cell as StateCell, collections::VecDeque, fs, path::Path, rc::Rc}; + use std::{ + cell::Cell as StateCell, collections::VecDeque, convert::Infallible, fs, path::Path, rc::Rc, + }; use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; use libc::{SIGHUP, SIGTERM}; - use ratatui::backend::TestBackend; + use ratatui::{ + backend::{ClearType, TestBackend, WindowSize}, + layout::{Position, Size}, + }; use tempfile::tempdir; use super::*; - use crate::app::{ViewState, app_from_text}; + use crate::app::{ + RenderRows, RenderRowsView, RenderState, SearchStatus, ViewState, app_from_text, + }; + + fn infallible(result: Result) -> io::Result { + match result { + Ok(value) => Ok(value), + Err(error) => match error {}, + } + } + + struct IoTestBackend { + inner: TestBackend, + draw_calls: usize, + flush_calls: usize, + fail_draw: bool, + } + + impl IoTestBackend { + fn new(width: u16, height: u16) -> Self { + Self { + inner: TestBackend::new(width, height), + draw_calls: 0, + flush_calls: 0, + fail_draw: false, + } + } + } + + impl Backend for IoTestBackend { + type Error = io::Error; + + fn draw<'a, I>(&mut self, content: I) -> io::Result<()> + where + I: Iterator, + { + self.draw_calls += 1; + if self.fail_draw { + return Err(io::Error::other("injected backend draw failure")); + } + infallible(self.inner.draw(content)) + } + + fn hide_cursor(&mut self) -> io::Result<()> { + infallible(self.inner.hide_cursor()) + } + + fn show_cursor(&mut self) -> io::Result<()> { + infallible(self.inner.show_cursor()) + } + + fn get_cursor_position(&mut self) -> io::Result { + infallible(self.inner.get_cursor_position()) + } + + fn set_cursor_position>(&mut self, position: P) -> io::Result<()> { + infallible(self.inner.set_cursor_position(position)) + } + + fn clear(&mut self) -> io::Result<()> { + infallible(self.inner.clear()) + } + + fn clear_region(&mut self, clear_type: ClearType) -> io::Result<()> { + infallible(self.inner.clear_region(clear_type)) + } + + fn size(&self) -> io::Result { + infallible(self.inner.size()) + } + + fn window_size(&mut self) -> io::Result { + infallible(self.inner.window_size()) + } + + fn flush(&mut self) -> io::Result<()> { + self.flush_calls += 1; + infallible(self.inner.flush()) + } + } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum DrawKind { @@ -1082,6 +1242,7 @@ mod tests { MAX_TERMINAL_CELLS * 2 * u64::try_from(size_of::()).unwrap() ); assert!(boundary.buffer_bytes() <= MAX_TERMINAL_BUFFER_BYTES); + assert_eq!(boundary.symbol_budget(), 8 * 1024 * 1024); assert!(TerminalSize::new(1000, 300).is_ok()); assert!(TerminalSize::new(0, u16::MAX).is_ok()); assert!(matches!( @@ -1102,6 +1263,139 @@ mod tests { )); } + #[test] + fn frame_symbol_meter_uses_locked_compact_string_capacity_boundaries() { + for (length, expected) in [(24, 0), (25, 32), (31, 32), (32, 32), (33, 33)] { + let mut meter = FrameSymbolMeter::new(u64::MAX); + meter.charge(&"x".repeat(length)).unwrap(); + assert_eq!(meter.used(), expected); + } + + let mut exact = FrameSymbolMeter::new(32); + exact.charge(&"x".repeat(25)).unwrap(); + assert_eq!(exact.used(), 32); + let error = exact.charge(&"s".repeat(25)).unwrap_err(); + assert!(matches!( + error, + TutError::TerminalFrameSymbolBudgetExceeded { limit: 32 } + )); + assert_eq!(exact.used(), 32); + assert!(!error.message().contains(&"s".repeat(25))); + + let mut next_byte = FrameSymbolMeter::new(32); + let error = next_byte.charge(&"x".repeat(33)).unwrap_err(); + assert!(matches!( + error, + TutError::TerminalFrameSymbolBudgetExceeded { limit: 32 } + )); + assert_eq!(next_byte.used(), 0); + } + + #[test] + fn maximum_geometry_rejects_large_body_symbols_without_publishing_a_frame() { + const COLUMNS: u16 = 4096; + const ROWS: u16 = 128; + const BODY_RUNS: usize = 8; + const GRAPHEMES_PER_RUN: usize = 4096; + + let grapheme = format!("a{}", "\u{0301}".repeat(495)); + assert_eq!(grapheme.len(), 991); + let run = grapheme.repeat(GRAPHEMES_PER_RUN); + assert_eq!(run.len(), 4_059_136); + assert_eq!(run.len() * BODY_RUNS, 32_473_088); + let rows = RenderRows::from_prebuilt_runs(&run, BODY_RUNS, COLUMNS).unwrap(); + let actual_storage_bytes = rows.storage_bytes(); + assert!( + actual_storage_bytes <= 32 * 1024 * 1024, + "prebuilt RenderRows storage uses {actual_storage_bytes} bytes, exceeding the 32 MiB limit" + ); + let state = ViewState::Reader(RenderState { + filename: "fixture", + path: "fixture", + rows: RenderRowsView::from_rows(&rows), + progress: 100, + current_line: Some(1), + total_lines: Some(8), + status: SearchStatus::None, + activity: None, + boundary: None, + repeat: None, + }); + let options = TerminalOptions { + viewport: Viewport::Fixed(Rect::new(0, 0, COLUMNS, ROWS)), + }; + let backend = IoTestBackend::new(COLUMNS, ROWS); + let mut terminal = Terminal::with_options(backend, options).unwrap(); + let frame_count = terminal.get_frame().count(); + let current_buffer = std::ptr::from_mut(terminal.current_buffer_mut()); + + let error = try_draw_fallible(&mut terminal, |frame| render_budgeted_frame(frame, &state)) + .unwrap_err(); + + assert!(matches!( + error, + TutError::TerminalFrameSymbolBudgetExceeded { limit: 8_388_608 } + )); + assert_eq!( + error.message(), + "terminal frame symbols exceed the 8388608-byte limit" + ); + assert_eq!(terminal.backend().draw_calls, 0); + assert_eq!(terminal.backend().flush_calls, 0); + assert_eq!(terminal.get_frame().count(), frame_count); + assert_eq!( + std::ptr::from_mut(terminal.current_buffer_mut()), + current_buffer + ); + assert!( + terminal + .current_buffer_mut() + .content() + .iter() + .all(|cell| cell == &Cell::default()) + ); + assert!( + terminal + .backend() + .inner + .buffer() + .content() + .iter() + .all(|cell| cell == &Cell::default()) + ); + + let retry = ViewState::Help { q_closes: true }; + try_draw_fallible(&mut terminal, |frame| render_budgeted_frame(frame, &retry)).unwrap(); + assert_eq!(terminal.backend().draw_calls, 1); + assert_eq!(terminal.backend().flush_calls, 1); + assert_eq!(terminal.get_frame().count(), frame_count + 1); + } + + #[test] + fn backend_draw_failures_are_not_mislabeled_as_budget_rejections() { + let options = TerminalOptions { + viewport: Viewport::Fixed(Rect::new(0, 0, 16, 4)), + }; + let mut backend = IoTestBackend::new(16, 4); + backend.fail_draw = true; + let mut terminal = Terminal::with_options(backend, options).unwrap(); + let state = ViewState::Help { q_closes: true }; + + let error = try_draw_fallible(&mut terminal, |frame| render_budgeted_frame(frame, &state)) + .unwrap_err(); + + assert!(matches!( + error, + TutError::Io { + operation: "draw terminal frame", + .. + } + )); + assert!(error.message().contains("injected backend draw failure")); + assert_eq!(terminal.backend().draw_calls, 1); + assert_eq!(terminal.backend().flush_calls, 0); + } + #[test] fn fixed_terminals_do_not_autoresize_during_draw() { let fixed = Rect::new(0, 0, 10, 5); diff --git a/src/tui/view.rs b/src/tui/view.rs index ab0e12f..ad239f4 100644 --- a/src/tui/view.rs +++ b/src/tui/view.rs @@ -5,7 +5,6 @@ use ratatui::{ buffer::{Buffer, CellDiffOption}, layout::Rect, style::{Modifier, Style}, - widgets::Widget, }; use unicode_segmentation::UnicodeSegmentation; @@ -17,6 +16,7 @@ use crate::{ }, error::{TutError, sanitize_text}, layout::{ContentWidth, DisplayAtoms, DisplayColumn}, + tui::FrameSymbolMeter, }; const PENDING_MESSAGE: &str = "Preparing view…"; @@ -179,25 +179,34 @@ const FULL_HELP: &[&str] = &[ " Ctrl-C interrupt", ]; -pub(super) fn render(frame: &mut Frame<'_>, state: &ViewState<'_>) -> Result<(), TutError> { +pub(super) fn render( + frame: &mut Frame<'_>, + state: &ViewState<'_>, + symbols: &mut FrameSymbolMeter, +) -> Result<(), TutError> { let area = frame.area(); if area.width < MIN_TERMINAL_COLUMNS || area.height < MIN_TERMINAL_ROWS { render_projected_line( - frame, + frame.buffer_mut(), Rect::new(area.x, area.y, area.width, area.height.min(1)), pick_copy(area.width, TINY_COPY), + symbols, )?; return Ok(()); } match state { - ViewState::Reader(state) => render_reader(frame, state), - ViewState::Pending(state) => render_pending(frame, state), - ViewState::Help { q_closes } => render_help(frame, *q_closes), + ViewState::Reader(state) => render_reader(frame, state, symbols), + ViewState::Pending(state) => render_pending(frame, state, symbols), + ViewState::Help { q_closes } => render_help(frame, *q_closes, symbols), } } -fn render_reader(frame: &mut Frame<'_>, state: &RenderState<'_>) -> Result<(), TutError> { +fn render_reader( + frame: &mut Frame<'_>, + state: &RenderState<'_>, + symbols: &mut FrameSymbolMeter, +) -> Result<(), TutError> { let area = frame.area(); let header_text = header_text(state.filename, state.path, area.width)?; let status_text = status_text(state, area.width)?; @@ -208,14 +217,18 @@ fn render_reader(frame: &mut Frame<'_>, state: &RenderState<'_>) -> Result<(), T let status = Rect::new(area.x, area.y + 1 + body_height, area.width, 1); let help = Rect::new(area.x, area.y + 2 + body_height, area.width, 1); - render_projected_line(frame, header, &header_text)?; - render_body(frame, body, state.rows); - render_projected_line(frame, status, &status_text)?; - render_projected_line(frame, help, help_text)?; + render_projected_line(frame.buffer_mut(), header, &header_text, symbols)?; + render_body(frame.buffer_mut(), body, state.rows, symbols)?; + render_projected_line(frame.buffer_mut(), status, &status_text, symbols)?; + render_projected_line(frame.buffer_mut(), help, help_text, symbols)?; Ok(()) } -fn render_pending(frame: &mut Frame<'_>, state: &PendingState<'_>) -> Result<(), TutError> { +fn render_pending( + frame: &mut Frame<'_>, + state: &PendingState<'_>, + symbols: &mut FrameSymbolMeter, +) -> Result<(), TutError> { let area = frame.area(); let header_text = header_text(state.filename, state.path, area.width)?; let status_text = pending_status_text(state.status, state.repeat, area.width)?; @@ -226,10 +239,10 @@ fn render_pending(frame: &mut Frame<'_>, state: &PendingState<'_>) -> Result<(), let status = Rect::new(area.x, area.y + 1 + body_height, area.width, 1); let footer = Rect::new(area.x, area.y + 2 + body_height, area.width, 1); - render_projected_line(frame, header, &header_text)?; - render_centered_line(frame, body, PENDING_MESSAGE)?; - render_projected_line(frame, status, &status_text)?; - render_projected_line(frame, footer, footer_text) + render_projected_line(frame.buffer_mut(), header, &header_text, symbols)?; + render_centered_line(frame.buffer_mut(), body, PENDING_MESSAGE, symbols)?; + render_projected_line(frame.buffer_mut(), status, &status_text, symbols)?; + render_projected_line(frame.buffer_mut(), footer, footer_text, symbols) } fn pick_copy(columns: u16, tiers: &[CopyTier]) -> &'static str { @@ -247,7 +260,11 @@ fn footer_for(status: SearchStatus<'_>, columns: u16) -> &'static str { } } -fn render_help(frame: &mut Frame<'_>, q_closes: bool) -> Result<(), TutError> { +fn render_help( + frame: &mut Frame<'_>, + q_closes: bool, + symbols: &mut FrameSymbolMeter, +) -> Result<(), TutError> { let area = frame.area(); let title = Rect::new(area.x, area.y, area.width, 1); let footer = Rect::new(area.x, area.bottom() - 1, area.width, 1); @@ -258,10 +275,15 @@ fn render_help(frame: &mut Frame<'_>, q_closes: bool) -> Result<(), TutError> { COMPACT_HELP }; - render_projected_line(frame, title, HELP_TITLE)?; + render_projected_line(frame.buffer_mut(), title, HELP_TITLE, symbols)?; for (relative_y, line) in lines.iter().take(usize::from(body_height)).enumerate() { let y = area.y + 1 + u16::try_from(relative_y).expect("help rows fit the terminal height"); - render_projected_line(frame, Rect::new(area.x, y, area.width, 1), line)?; + render_projected_line( + frame.buffer_mut(), + Rect::new(area.x, y, area.width, 1), + line, + symbols, + )?; } let footer_text = pick_copy( area.width, @@ -271,26 +293,21 @@ fn render_help(frame: &mut Frame<'_>, q_closes: bool) -> Result<(), TutError> { SEARCH_HELP_FOOTER }, ); - render_projected_line(frame, footer, footer_text) -} - -fn render_body(frame: &mut Frame<'_>, area: Rect, rows: RenderRowsView<'_>) { - frame.render_widget(ReaderBody { rows }, area); -} - -struct ReaderBody<'a> { - rows: RenderRowsView<'a>, + render_projected_line(frame.buffer_mut(), footer, footer_text, symbols) } -impl Widget for ReaderBody<'_> { - fn render(self, area: Rect, buffer: &mut Buffer) { - let mut highlights = self.rows.highlight_cursor(); - for (relative_y, row) in self.rows.iter().take(usize::from(area.height)).enumerate() { - let y = - area.y + u16::try_from(relative_y).expect("visible row count fits terminal height"); - write_render_row(buffer, area, y, row, &mut highlights); - } +fn render_body( + buffer: &mut Buffer, + area: Rect, + rows: RenderRowsView<'_>, + symbols: &mut FrameSymbolMeter, +) -> Result<(), TutError> { + let mut highlights = rows.highlight_cursor(); + for (relative_y, row) in rows.iter().take(usize::from(area.height)).enumerate() { + let y = area.y + u16::try_from(relative_y).expect("visible row count fits terminal height"); + write_render_row(buffer, area, y, row, &mut highlights, symbols)?; } + Ok(()) } fn write_render_row( @@ -299,8 +316,13 @@ fn write_render_row( y: u16, row: RenderRow<'_>, highlights: &mut MatchCursor<'_>, -) { - let mut x = area.x; + symbols: &mut FrameSymbolMeter, +) -> Result<(), TutError> { + let mut cursor = RowCursor { + x: area.x, + y, + right: area.right(), + }; let mut pending: Option<(RenderSpan, Highlight)> = None; for span in row.spans { let highlight = highlights.role_for(span.source()); @@ -311,33 +333,54 @@ fn write_render_row( continue; } if let Some((current, current_highlight)) = pending.take() - && !write_render_run(buffer, area, &mut x, y, row, ¤t, current_highlight) + && !write_render_run( + buffer, + &mut cursor, + row, + ¤t, + current_highlight, + symbols, + )? { - return; + return Ok(()); } pending = Some((span.clone(), highlight)); } if let Some((span, highlight)) = pending { - write_render_run(buffer, area, &mut x, y, row, &span, highlight); + write_render_run(buffer, &mut cursor, row, &span, highlight, symbols)?; } + Ok(()) +} + +struct RowCursor { + x: u16, + y: u16, + right: u16, } fn write_render_run( buffer: &mut Buffer, - area: Rect, - x: &mut u16, - y: u16, + cursor: &mut RowCursor, row: RenderRow<'_>, span: &RenderSpan, highlight: Highlight, -) -> bool { + symbols: &mut FrameSymbolMeter, +) -> Result { let width = u16::try_from(span.cell_width.get()).expect("projected width fits the terminal width"); - if width > area.right().saturating_sub(*x) { - return false; - } - *x += write_render_span(buffer, *x, y, row.span_text(span), span, highlight); - true + if width > cursor.right.saturating_sub(cursor.x) { + return Ok(false); + } + cursor.x += write_render_span( + buffer, + cursor.x, + cursor.y, + row.span_text(span), + span, + highlight, + symbols, + )?; + Ok(true) } fn write_render_span( @@ -347,7 +390,8 @@ fn write_render_span( text: &str, span: &RenderSpan, highlight: Highlight, -) -> u16 { + symbols: &mut FrameSymbolMeter, +) -> Result { let width = u16::try_from(span.cell_width.get()).expect("projected width fits the terminal width"); let style = style_for(highlight); @@ -359,11 +403,12 @@ fn write_render_span( .cell_mut((x + offset, y)) .expect("span was clipped to the buffer area"); cell.reset(); + symbols.charge(" ")?; cell.set_symbol(" ") .set_style(style) .set_diff_option(CellDiffOption::ForcedWidth(one)); } - return width; + return Ok(width); } let forced_width = NonZeroU16::new(width).expect("projected atoms have nonzero width"); @@ -371,6 +416,7 @@ fn write_render_span( .cell_mut((x, y)) .expect("span was clipped to the buffer area"); cell.reset(); + symbols.charge(text)?; cell.set_symbol(text) .set_style(style) .set_diff_option(CellDiffOption::ForcedWidth(forced_width)); @@ -381,7 +427,7 @@ fn write_render_span( cell.reset(); cell.set_diff_option(CellDiffOption::Skip); } - width + Ok(width) } fn style_for(highlight: Highlight) -> Style { @@ -393,7 +439,12 @@ fn style_for(highlight: Highlight) -> Style { } } -fn render_projected_line(frame: &mut Frame<'_>, area: Rect, text: &str) -> Result<(), TutError> { +fn render_projected_line( + buffer: &mut Buffer, + area: Rect, + text: &str, + symbols: &mut FrameSymbolMeter, +) -> Result<(), TutError> { let Some(content_width) = ContentWidth::new(area.width) else { return Ok(()); }; @@ -415,19 +466,25 @@ fn render_projected_line(frame: &mut Frame<'_>, area: Rect, text: &str) -> Resul break; } x += write_render_span( - frame.buffer_mut(), + buffer, x, area.y, span.standalone_text(&row), &span, Highlight::None, - ); + symbols, + )?; column = DisplayColumn::new(column.get() + u32::from(width)); } Ok(()) } -fn render_centered_line(frame: &mut Frame<'_>, area: Rect, text: &str) -> Result<(), TutError> { +fn render_centered_line( + buffer: &mut Buffer, + area: Rect, + text: &str, + symbols: &mut FrameSymbolMeter, +) -> Result<(), TutError> { if area.width == 0 || area.height == 0 { return Ok(()); } @@ -436,9 +493,10 @@ fn render_centered_line(frame: &mut Frame<'_>, area: Rect, text: &str) -> Result let x = area.x + area.width.saturating_sub(used) / 2; let y = area.y + area.height.saturating_sub(1) / 2; render_projected_line( - frame, + buffer, Rect::new(x, y, area.right().saturating_sub(x), 1), &shown, + symbols, ) } @@ -747,8 +805,9 @@ mod tests { fn draw_available_into(terminal: &mut Terminal, app: &mut crate::app::App) { let state = app.view_state().unwrap(); + let mut symbols = FrameSymbolMeter::new(u64::MAX); terminal - .draw(|frame| render(frame, &state).unwrap()) + .draw(|frame| render(frame, &state, &mut symbols).unwrap()) .unwrap(); } @@ -771,7 +830,8 @@ mod tests { let mut buffer = Buffer::empty(area); prepare_frame(app); let state = app.render_state().unwrap(); - ReaderBody { rows: state.rows }.render(area, &mut buffer); + let mut symbols = FrameSymbolMeter::new(u64::MAX); + render_body(&mut buffer, area, state.rows, &mut symbols).unwrap(); buffer } @@ -783,6 +843,21 @@ mod tests { .to_owned() } + fn actual_symbol_heap_bytes(buffer: &Buffer) -> u64 { + buffer + .content() + .iter() + .map(|cell| { + let length = cell.symbol().len(); + if length <= 24 { + 0 + } else { + u64::try_from(length.max(32)).unwrap() + } + }) + .sum() + } + fn assert_copy_tiers(tiers: &[CopyTier]) { assert!( tiers @@ -864,6 +939,88 @@ mod tests { assert_eq!(pick_copy(16, TINY_COPY), "resize q quit"); } + #[test] + fn body_chrome_and_tiny_paths_charge_actual_buffer_symbols() { + let heap_grapheme = format!("a{}", "\u{0301}".repeat(12)); + assert_eq!(heap_grapheme.len(), 25); + + let mut app = app_from_text(Path::new("/tmp/body.txt"), heap_grapheme.clone()); + app.update(Action::Resize(Geometry::new(16, 4))).unwrap(); + prepare_frame(&mut app); + let state = app.render_state().unwrap(); + let area = Rect::new(0, 0, 16, 1); + let mut body = Buffer::empty(area); + let mut body_symbols = FrameSymbolMeter::new(u64::MAX); + render_body(&mut body, area, state.rows, &mut body_symbols).unwrap(); + assert_eq!(body.cell((0, 0)).unwrap().symbol(), heap_grapheme); + assert_eq!(body_symbols.used(), 32); + assert_eq!(body_symbols.used(), actual_symbol_heap_bytes(&body)); + + let mut chrome = Buffer::empty(Rect::new(0, 0, 4, 1)); + let mut chrome_symbols = FrameSymbolMeter::new(u64::MAX); + render_projected_line( + &mut chrome, + Rect::new(0, 0, 4, 1), + &heap_grapheme, + &mut chrome_symbols, + ) + .unwrap(); + assert_eq!(chrome.cell((0, 0)).unwrap().symbol(), heap_grapheme); + assert_eq!(chrome_symbols.used(), 32); + assert_eq!(chrome_symbols.used(), actual_symbol_heap_bytes(&chrome)); + + let mut tiny_app = app_from_text(Path::new("/tmp/tiny.txt"), "body".to_owned()); + let state = tiny_app.view_state().unwrap(); + let mut terminal = Terminal::new(TestBackend::new(12, 3)).unwrap(); + let mut tiny_symbols = FrameSymbolMeter::new(u64::MAX); + terminal + .draw(|frame| render(frame, &state, &mut tiny_symbols).unwrap()) + .unwrap(); + assert_eq!(row_text(terminal.backend().buffer(), 0), "resize q"); + assert_eq!(tiny_symbols.used(), 0); + assert_eq!( + tiny_symbols.used(), + actual_symbol_heap_bytes(terminal.backend().buffer()) + ); + } + + #[test] + fn fresh_frames_and_smaller_resizes_do_not_accumulate_symbol_charges() { + let heap_grapheme = format!("a{}", "\u{0301}".repeat(12)); + let mut terminal = Terminal::new(TestBackend::new(4, 1)).unwrap(); + + for _ in 0..2 { + let mut symbols = FrameSymbolMeter::new(32); + terminal + .draw(|frame| { + let area = frame.area(); + render_projected_line(frame.buffer_mut(), area, &heap_grapheme, &mut symbols) + .unwrap(); + }) + .unwrap(); + assert_eq!(symbols.used(), 32); + assert_eq!(actual_symbol_heap_bytes(terminal.backend().buffer()), 32); + } + + terminal.resize(Rect::new(0, 0, 2, 1)).unwrap(); + let mut symbols = FrameSymbolMeter::new(0); + terminal + .draw(|frame| { + let area = frame.area(); + render_projected_line(frame.buffer_mut(), area, "x", &mut symbols).unwrap(); + }) + .unwrap(); + assert_eq!(symbols.used(), 0); + assert_eq!(actual_symbol_heap_bytes(terminal.backend().buffer()), 0); + assert!( + terminal + .current_buffer_mut() + .content() + .iter() + .all(|cell| cell == &ratatui::buffer::Cell::default()) + ); + } + #[test] fn frame_renders_fixed_regions_and_empty_progress() { let mut app = app_from_text(Path::new("/tmp/book.txt"), String::new());