//! The buffer widget — the only widget mojibake presents (MJB-HLR-019). //! //! Rendering is deliberately thin: it asks [`View::visible_lines`] for at most //! `height` line slices and draws those. Nothing here walks the document, so //! per-frame cost is O(viewport) however large the file (MJB-LLR-200). use std::path::PathBuf; use color_eyre::eyre::eyre; use crossterm::event::KeyEvent; use ratatui::{ Frame, layout::{Constraint, Layout, Rect}, style::{Style, Stylize}, text::{Line, Span}, widgets::Paragraph, }; use tokio::sync::mpsc::UnboundedSender; use super::Component; use crate::{ action::Action, buffer::{ Buffer, LINE_TYPE, Outcome, grapheme::{display_column, grapheme_str, grapheme_width, next_grapheme_boundary}, }, config::{Config, Mode}, }; pub struct BufferComponent { buffer: Buffer, command_tx: Option>, } impl BufferComponent { pub fn new(config: Config, path: Option) -> color_eyre::Result { let buffer = Buffer::new(config, path).map_err(|e| eyre!("{e}"))?; Ok(Self { buffer, command_tx: None, }) } pub fn buffer(&self) -> &Buffer { &self.buffer } /// Width of the line-number gutter, sized to the largest line number. /// /// Counts digits arithmetically rather than formatting the number, since /// this runs every frame. `u16` cannot truncate here in practice — it would /// take more than 10^65000 lines — but the conversion is still checked /// rather than cast, and saturates to the terminal's own maximum width. fn gutter_width(&self) -> u16 { let lines = self.buffer.document.len_lines().max(1); let digits = lines.ilog10() as usize + 1; u16::try_from(digits + 1).unwrap_or(u16::MAX) // digits + one space of padding } /// MJB-LLR-202: mode, path, modified marker and cursor position. fn status_line(&self) -> String { let doc = &self.buffer.document; let name = doc .path() .map(|p| p.display().to_string()) .unwrap_or_else(|| "[scratch]".to_owned()); let modified = if doc.is_modified() { " [+]" } else { "" }; let text = doc.slice(); let cursor = doc.range().cursor(text); let line = text.byte_to_line_idx(cursor, LINE_TYPE); let line_start = text.line_to_byte_idx(line, LINE_TYPE); let col = display_column(text.line(line, LINE_TYPE), cursor - line_start); format!( " {} {name}{modified} {}:{} ", self.buffer.mode, line + 1, col + 1 ) } /// The bottom row: the command line while in command mode, otherwise any /// transient message, otherwise the pending key sequence. fn message_line(&self) -> String { if self.buffer.mode == Mode::Command { return format!(":{}", self.buffer.command_line); } if let Some(msg) = &self.buffer.status { return msg.clone(); } if !self.buffer.pending_keys().is_empty() { let keys: String = self .buffer .pending_keys() .iter() .map(crate::config::key_event_to_string) .collect(); return keys; } String::new() } } impl Component for BufferComponent { fn register_action_handler(&mut self, tx: UnboundedSender) -> color_eyre::Result<()> { self.command_tx = Some(tx); Ok(()) } fn register_config_handler(&mut self, _config: Config) -> color_eyre::Result<()> { // The config is supplied at construction, because the document must be // opened with it; re-registering would discard buffer state. Ok(()) } fn handle_key_event(&mut self, key: KeyEvent) -> color_eyre::Result> { Ok(match self.buffer.handle_key(key) { Outcome::Consumed => None, Outcome::Quit => Some(Action::Quit), Outcome::Suspend => Some(Action::Suspend), }) } fn draw(&mut self, frame: &mut Frame, area: Rect) -> color_eyre::Result<()> { // MJB-LLR-202: reserve the last two rows for status and message. let [text_area, status_area, message_area] = Layout::vertical([ Constraint::Min(0), Constraint::Length(1), Constraint::Length(1), ]) .areas(area); let gutter = self.gutter_width(); let [gutter_area, content_area] = Layout::horizontal([Constraint::Length(gutter), Constraint::Min(0)]).areas(text_area); let height = content_area.height as usize; let width = content_area.width as usize; self.buffer.update_view(width, height); let mode = self.buffer.mode; let cfg = self.buffer.config(); let cursor_style = cfg.style(mode, "cursor"); let selection_style = cfg.style(mode, "selection"); let linenr_style = cfg.style(mode, "linenr"); let status_style = cfg.style(mode, "statusline"); let doc = &self.buffer.document; let text = doc.slice(); let range = doc.range(); let cursor_byte = range.cursor(text); let sel_from = range.from(); let sel_to = range.to(); let (top_line, count) = self.buffer.view.visible_line_range(text, height); let h_offset = self.buffer.view.offset.horizontal_offset; let mut rows: Vec = Vec::with_capacity(count); let mut gutter_rows: Vec = Vec::with_capacity(count); // MJB-LLR-200: only the visible lines are touched. for (i, line) in self.buffer.view.visible_lines(text, height).enumerate() { let line_idx = top_line + i; let line_start = text.line_to_byte_idx(line_idx, LINE_TYPE); gutter_rows.push(Line::from(Span::styled( format!("{:>w$} ", line_idx + 1, w = (gutter as usize).saturating_sub(1)), linenr_style, ))); // MJB-LLR-201: style the cursor and the selection span. let mut spans: Vec = Vec::new(); let mut column = 0usize; let mut byte = 0usize; let line_len = line.len(); while byte < line_len { let next = next_grapheme_boundary(line, byte); if next <= byte { break; } // Borrows from the rope unless the grapheme straddles a chunk. let g = grapheme_str(line, byte..next); if matches!(g.as_ref(), "\n" | "\r\n" | "\r") { break; } let abs = line_start + byte; let w = grapheme_width(&g, column); let style = if abs == cursor_byte { cursor_style } else if abs >= sel_from && abs < sel_to { selection_style } else { Style::default() }; // Horizontal scrolling: skip graphemes left of the offset. if column + w > h_offset { // A tab is stored as one byte but occupies `w` columns. let rendered = if g == "\t" { " ".repeat(w) } else { g.into_owned() }; spans.push(Span::styled(rendered, style)); } column += w; byte = next; } // A cursor sitting past the last character (end of line, or an // empty line) still needs a visible block. if cursor_byte == line_start + byte && cursor_byte >= line_start { spans.push(Span::styled(" ", cursor_style)); } rows.push(Line::from(spans)); } frame.render_widget(Paragraph::new(gutter_rows), gutter_area); frame.render_widget(Paragraph::new(rows), content_area); frame.render_widget( Paragraph::new(Line::from(Span::styled(self.status_line(), status_style))), status_area, ); frame.render_widget( Paragraph::new(Line::from(self.message_line())).dim(), message_area, ); Ok(()) } }