diff options
| author | rottedfm <rottedfm@proton.me> | 2026-08-19 11:21:47 -0400 |
|---|---|---|
| committer | rottedfm <rottedfm@proton.me> | 2026-08-19 11:21:47 -0400 |
| commit | ea0bd36167b684c0accdb5ce2b2e21b8d84aeb25 (patch) | |
| tree | be1267972b5de2f1ae592577dfabce67f1fe6e87 /src/components/buffer.rs | |
| parent | 8c0b4c53b130555f040884c1f52b90f16b23e241 (diff) | |
feat: implement Helix-style modal buffer under DO-178C DAL-C
The repository was an unmodified ratatui component template: no editor
code, JSON5 config, and placeholder widgets. This establishes the first
working baseline — `moji <file>` opens a file into a ropey rope and edits
it with Helix selection-first semantics.
Requirements, implementation and tests land together because they must:
the traceability check rejects requirements with no implementation and
tests naming requirements that do not exist, so neither half is a valid
commit on its own.
Package renamed to mojibake-editor (mojibake was taken on crates.io);
binary is moji, library target stays mojibake.
Class: New behaviour
Requirements: MJB-HLR-001..019, MJB-LLR-001..205
Derived: MJB-DR-001..007 (DR-001 resolved, six open for review)
Verified: cargo build; clippy --all-targets -D warnings clean;
cargo test 294 passing; ./scripts/check-trace.sh 98/98/98;
cargo package clean
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'src/components/buffer.rs')
| -rw-r--r-- | src/components/buffer.rs | 237 |
1 files changed, 237 insertions, 0 deletions
diff --git a/src/components/buffer.rs b/src/components/buffer.rs new file mode 100644 index 0000000..360ef7c --- /dev/null +++ b/src/components/buffer.rs @@ -0,0 +1,237 @@ +//! 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<UnboundedSender<Action>>, +} + +impl BufferComponent { + pub fn new(config: Config, path: Option<PathBuf>) -> color_eyre::Result<Self> { + 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<Action>) -> 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<Option<Action>> { + 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<Line> = Vec::with_capacity(count); + let mut gutter_rows: Vec<Line> = 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<Span> = 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(()) + } +} |
