aboutsummaryrefslogtreecommitdiff
path: root/src/components
diff options
context:
space:
mode:
authorrottedfm <rottedfm@proton.me>2026-08-19 11:24:55 -0400
committerrottedfm <rottedfm@proton.me>2026-08-19 11:24:55 -0400
commit8e16347b0eb329e84892af8ece36886324c95f62 (patch)
treebe1267972b5de2f1ae592577dfabce67f1fe6e87 /src/components
parentc6ae4660d1cc2414b22c492c5e819d009c8187c2 (diff)
parentea0bd36167b684c0accdb5ce2b2e21b8d84aeb25 (diff)
Merge branch 'buffer-implementation'
Establishes the first working baseline: moji <file> opens a file into a ropey rope and edits it with Helix selection-first modal editing, under a DO-178C DAL-C requirements and traceability process. Prior to this, main tracked four files and src/main.rs was still println!("Hello, world!") — there was no buildable state to build on. Verified on a fresh clone of the branch with no untracked files: cargo build; clippy --all-targets -D warnings clean; 294 tests passing; scripts/check-trace.sh reports 98/98 requirements traced in both directions. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'src/components')
-rw-r--r--src/components/buffer.rs237
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(())
+ }
+}