aboutsummaryrefslogtreecommitdiff
path: root/src
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
parentc6ae4660d1cc2414b22c492c5e819d009c8187c2 (diff)
parentea0bd36167b684c0accdb5ce2b2e21b8d84aeb25 (diff)
Merge branch 'buffer-implementation'HEADmain
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')
-rw-r--r--src/action.rs17
-rw-r--r--src/app.rs176
-rw-r--r--src/buffer/command.rs147
-rw-r--r--src/buffer/document.rs525
-rw-r--r--src/buffer/encoding.rs272
-rw-r--r--src/buffer/grapheme.rs325
-rw-r--r--src/buffer/history.rs228
-rw-r--r--src/buffer/keymap.rs392
-rw-r--r--src/buffer/line_ending.rs144
-rw-r--r--src/buffer/mod.rs615
-rw-r--r--src/buffer/movement.rs592
-rw-r--r--src/buffer/save.rs387
-rw-r--r--src/buffer/selection.rs344
-rw-r--r--src/buffer/transaction.rs424
-rw-r--r--src/buffer/view.rs371
-rw-r--r--src/cli.rs54
-rw-r--r--src/components.rs125
-rw-r--r--src/components/buffer.rs237
-rw-r--r--src/config.rs888
-rw-r--r--src/errors.rs77
-rw-r--r--src/lib.rs18
-rw-r--r--src/logging.rs36
-rw-r--r--src/main.rs15
-rw-r--r--src/tui.rs233
24 files changed, 6640 insertions, 2 deletions
diff --git a/src/action.rs b/src/action.rs
new file mode 100644
index 0000000..29ee079
--- /dev/null
+++ b/src/action.rs
@@ -0,0 +1,17 @@
+use serde::{Deserialize, Serialize};
+use strum::Display;
+
+/// Application-level events, distinct from [`crate::buffer::command::Command`],
+/// which is what key bindings name. `Action` is what the event loop routes;
+/// `Command` is what the editor executes.
+#[derive(Debug, Clone, PartialEq, Eq, Display, Serialize, Deserialize)]
+pub enum Action {
+ Tick,
+ Render,
+ Resize(u16, u16),
+ Suspend,
+ Resume,
+ Quit,
+ ClearScreen,
+ Error(String),
+}
diff --git a/src/app.rs b/src/app.rs
new file mode 100644
index 0000000..063a30a
--- /dev/null
+++ b/src/app.rs
@@ -0,0 +1,176 @@
+use std::path::PathBuf;
+
+use crossterm::event::KeyEvent;
+use ratatui::prelude::Rect;
+use tokio::sync::mpsc;
+use tracing::{debug, info};
+
+use crate::{
+ action::Action,
+ buffer::{command::Command, keymap::Keymap},
+ components::{Component, buffer::BufferComponent},
+ config::{Config, Mode},
+ tui::{Event, Tui},
+};
+
+pub struct App {
+ config: Config,
+ tick_rate: f64,
+ frame_rate: f64,
+ components: Vec<Box<dyn Component>>,
+ should_quit: bool,
+ should_suspend: bool,
+ /// MJB-LLR-203: resolves only the `Global` scope. Every other mode belongs
+ /// to the buffer — see MJB-DR-004 for why ownership is split.
+ global_keymap: Keymap,
+ action_tx: mpsc::UnboundedSender<Action>,
+ action_rx: mpsc::UnboundedReceiver<Action>,
+}
+
+impl App {
+ pub fn new(tick_rate: f64, frame_rate: f64, file: Option<PathBuf>) -> color_eyre::Result<Self> {
+ let (action_tx, action_rx) = mpsc::unbounded_channel();
+ let config = Config::new()?;
+ let global_keymap = Keymap::new(&config.keybindings);
+
+ // MJB-LLR-204: the buffer is the only component. The template's
+ // FpsCounter and Home widgets are gone, not merely unregistered.
+ let buffer = BufferComponent::new(config.clone(), file)?;
+
+ Ok(Self {
+ tick_rate,
+ frame_rate,
+ components: vec![Box::new(buffer)],
+ should_quit: false,
+ should_suspend: false,
+ config,
+ global_keymap,
+ action_tx,
+ action_rx,
+ })
+ }
+
+ pub async fn run(&mut self) -> color_eyre::Result<()> {
+ let mut tui = Tui::new()?
+ .tick_rate(self.tick_rate)
+ .frame_rate(self.frame_rate);
+ tui.enter()?;
+
+ for component in self.components.iter_mut() {
+ component.register_action_handler(self.action_tx.clone())?;
+ }
+ for component in self.components.iter_mut() {
+ component.register_config_handler(self.config.clone())?;
+ }
+ for component in self.components.iter_mut() {
+ component.init(tui.size()?)?;
+ }
+
+ let action_tx = self.action_tx.clone();
+ loop {
+ self.handle_events(&mut tui).await?;
+ self.handle_actions(&mut tui)?;
+ if self.should_suspend {
+ tui.suspend()?;
+ action_tx.send(Action::Resume)?;
+ action_tx.send(Action::ClearScreen)?;
+ tui.enter()?;
+ } else if self.should_quit {
+ tui.stop()?;
+ break;
+ }
+ }
+ tui.exit()?;
+ Ok(())
+ }
+
+ async fn handle_events(&mut self, tui: &mut Tui) -> color_eyre::Result<()> {
+ let Some(event) = tui.next_event().await else {
+ return Ok(());
+ };
+ let action_tx = self.action_tx.clone();
+
+ match event {
+ Event::Quit => action_tx.send(Action::Quit)?,
+ Event::Tick => action_tx.send(Action::Tick)?,
+ Event::Render => action_tx.send(Action::Render)?,
+ Event::Resize(x, y) => action_tx.send(Action::Resize(x, y))?,
+ // MJB-LLR-203: a global binding consumes the key outright. The
+ // template forwarded every key both here and to every component,
+ // which would fire `Quit` while typing in insert mode.
+ Event::Key(key) if self.handle_global_key(key)? => return Ok(()),
+ _ => {}
+ }
+
+ for component in self.components.iter_mut() {
+ if let Some(action) = component.handle_events(Some(event.clone()))? {
+ action_tx.send(action)?;
+ }
+ }
+ Ok(())
+ }
+
+ /// Returns whether the key was consumed by the global keymap.
+ fn handle_global_key(&mut self, key: KeyEvent) -> color_eyre::Result<bool> {
+ let Some(command) = self.global_keymap.lookup_single(Mode::Global, key) else {
+ return Ok(false);
+ };
+
+ let action = match command {
+ Command::Quit => Action::Quit,
+ Command::Suspend => Action::Suspend,
+ // Anything else bound globally is not an application concern; let
+ // the buffer handle it in its own mode.
+ _ => return Ok(false),
+ };
+
+ info!("Global binding matched: {command}");
+ self.action_tx.send(action)?;
+ Ok(true)
+ }
+
+ fn handle_actions(&mut self, tui: &mut Tui) -> color_eyre::Result<()> {
+ while let Ok(action) = self.action_rx.try_recv() {
+ if action != Action::Tick && action != Action::Render {
+ debug!("{action:?}");
+ }
+ match action {
+ // MJB-LLR-205: `Tick` no longer drains a pending-key buffer.
+ // Chord resolution is time-independent (MJB-LLR-154).
+ Action::Quit => self.should_quit = true,
+ Action::Suspend => self.should_suspend = true,
+ Action::Resume => self.should_suspend = false,
+ Action::ClearScreen => tui.terminal.clear()?,
+ Action::Resize(w, h) => self.handle_resize(tui, w, h)?,
+ Action::Render => self.render(tui)?,
+ Action::Error(ref err) => tracing::error!(?err),
+ _ => {}
+ }
+ for component in self.components.iter_mut() {
+ if let Some(action) = component.update(action.clone())? {
+ self.action_tx.send(action)?
+ };
+ }
+ }
+ Ok(())
+ }
+
+ fn handle_resize(&mut self, tui: &mut Tui, w: u16, h: u16) -> color_eyre::Result<()> {
+ tui.resize(Rect::new(0, 0, w, h))?;
+ self.render(tui)?;
+ Ok(())
+ }
+
+ fn render(&mut self, tui: &mut Tui) -> color_eyre::Result<()> {
+ tui.draw(|frame| {
+ for component in self.components.iter_mut() {
+ if let Err(err) = component.draw(frame, frame.area()) {
+ let _ = self
+ .action_tx
+ .send(Action::Error(format!("Failed to draw: {:?}", err)));
+ }
+ }
+ })?;
+ Ok(())
+ }
+}
diff --git a/src/buffer/command.rs b/src/buffer/command.rs
new file mode 100644
index 0000000..c5a2aef
--- /dev/null
+++ b/src/buffer/command.rs
@@ -0,0 +1,147 @@
+//! Editor commands — the values key bindings map to.
+//!
+//! Kept distinct from [`crate::action::Action`], which is application-level
+//! (tick, render, resize). A binding names a `Command`; the buffer executes it.
+//! `Quit` and `Suspend` appear here because the `Global` keymap is expressed in
+//! the same table and must be able to name them.
+
+use serde::{Deserialize, Serialize};
+use strum::Display;
+
+/// MJB-LLR-157: one unit variant per bound editor command, deserialized from
+/// the variant name so a TOML value like `"MoveCharLeft"` resolves directly.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Display, Serialize, Deserialize)]
+pub enum Command {
+ // --- Application (reachable from the `global` keymap) ---
+ Quit,
+ Suspend,
+
+ // --- Mode switching ---
+ NormalMode,
+ InsertMode,
+ SelectMode,
+ CommandMode,
+
+ // --- Character and line motion (MJB-HLR-006) ---
+ MoveCharLeft,
+ MoveCharRight,
+ MoveLineUp,
+ MoveLineDown,
+
+ // --- Word motion; these produce selections (MJB-HLR-007) ---
+ MoveNextWordStart,
+ MovePrevWordStart,
+ MoveNextWordEnd,
+ MoveNextLongWordStart,
+ MovePrevLongWordStart,
+ MoveNextLongWordEnd,
+
+ // --- Extending variants, used by select mode ---
+ ExtendCharLeft,
+ ExtendCharRight,
+ ExtendLineUp,
+ ExtendLineDown,
+ ExtendNextWordStart,
+ ExtendPrevWordStart,
+ ExtendNextWordEnd,
+
+ // --- Goto (MJB-HLR-008) ---
+ GotoFileStart,
+ GotoLastLine,
+ GotoLineStart,
+ GotoLineEnd,
+ GotoFirstNonWhitespace,
+
+ // --- Selection manipulation ---
+ ExtendLineBelow,
+ CollapseSelection,
+ FlipSelections,
+ SelectAll,
+
+ // --- Entering insert mode (MJB-HLR-009) ---
+ AppendMode,
+ InsertAtLineStart,
+ InsertAtLineEnd,
+ OpenBelow,
+ OpenAbove,
+
+ // --- Modification (MJB-HLR-010) ---
+ DeleteSelection,
+ ChangeSelection,
+ InsertNewline,
+ InsertTab,
+ DeleteCharBackward,
+ DeleteCharForward,
+ DeleteWordBackward,
+ KillToLineStart,
+
+ // --- Undo / redo (MJB-HLR-011) ---
+ Undo,
+ Redo,
+
+ // --- Scrolling and paging (MJB-HLR-013) ---
+ PageCursorHalfUp,
+ PageCursorHalfDown,
+ PageUp,
+ PageDown,
+
+ // --- Command line (MJB-HLR-016) ---
+ CommandSubmit,
+ CommandBackspace,
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// MJB-LLR-157: a TOML value naming a variant deserializes to it, which is
+ /// what makes the keymap config-driven.
+ #[test]
+ fn mjb_llr_157_deserializes_from_the_variant_name() {
+ let cmd: Command = serde_json_free_parse("MoveCharLeft");
+ assert_eq!(cmd, Command::MoveCharLeft);
+ assert_eq!(serde_json_free_parse("Undo"), Command::Undo);
+ assert_eq!(
+ serde_json_free_parse("PageCursorHalfDown"),
+ Command::PageCursorHalfDown
+ );
+ }
+
+ #[test]
+ fn mjb_llr_157_unknown_command_name_is_an_error_not_a_panic() {
+ let err = toml::from_str::<Wrapper>("cmd = \"NoSuchCommand\"").unwrap_err();
+ assert!(
+ err.to_string().contains("NoSuchCommand"),
+ "error must name the offending value, got: {err}"
+ );
+ }
+
+ #[test]
+ fn mjb_llr_157_display_round_trips_through_deserialization() {
+ for cmd in [
+ Command::Quit,
+ Command::GotoFileStart,
+ Command::DeleteSelection,
+ Command::CommandSubmit,
+ ] {
+ assert_eq!(
+ serde_json_free_parse(&cmd.to_string()),
+ cmd,
+ "{cmd} must round-trip"
+ );
+ }
+ }
+
+ #[derive(Debug, serde::Deserialize)]
+ struct Wrapper {
+ cmd: Command,
+ }
+
+ /// Parse a bare command name the way the keymap table does.
+ fn serde_json_free_parse(name: &str) -> Command {
+ let doc = format!("cmd = \"{name}\"");
+ toml::from_str::<Wrapper>(&doc)
+ .unwrap_or_else(|e| panic!("{name} must parse: {e}"))
+ .cmd
+ }
+}
diff --git a/src/buffer/document.rs b/src/buffer/document.rs
new file mode 100644
index 0000000..13c02ba
--- /dev/null
+++ b/src/buffer/document.rs
@@ -0,0 +1,525 @@
+//! The document: rope, associated path, encoding state, selection, history.
+//!
+//! **This is the only module that touches `ropey` types directly** as an owner.
+//! Confining the dependency here is the mitigation recorded in MJB-DR-006 for
+//! depending on a pre-release rope crate under DAL-C.
+
+use std::path::{Path, PathBuf};
+
+use ropey::{Rope, RopeSlice};
+
+use super::{
+ LINE_TYPE,
+ encoding::{self, DecodeError, EncodingInfo},
+ history::History,
+ line_ending::{self, LineEnding},
+ save::{self, SaveError},
+ selection::{Range, Selection},
+ transaction::{ChangeError, Transaction},
+};
+
+#[derive(Debug, thiserror::Error)]
+pub enum DocumentError {
+ #[error("{0}")]
+ Change(#[from] ChangeError),
+ #[error("{0}")]
+ Save(#[from] SaveError),
+ // MJB-LLR-118: opening a file mojibake cannot decode is a reportable
+ // failure, not something to paper over.
+ #[error("{0}")]
+ Decode(#[from] DecodeError),
+ #[error("io error: {0}")]
+ Io(#[from] std::io::Error),
+}
+
+#[derive(Debug)]
+pub struct Document {
+ text: Rope,
+ path: Option<PathBuf>,
+ encoding: EncodingInfo,
+ line_ending: LineEnding,
+ selection: Selection,
+ history: History,
+ /// The history revision the file on disk corresponds to.
+ ///
+ /// `modified` is derived from this rather than latched to a bool, so
+ /// undoing back to the last-saved state correctly reports the buffer as
+ /// clean. A latched flag would keep claiming unsaved changes for a buffer
+ /// byte-identical to its file. (MJB-LLR-116)
+ saved_revision: usize,
+}
+
+impl Default for Document {
+ fn default() -> Self {
+ Self::empty(None)
+ }
+}
+
+impl Document {
+ pub fn empty(path: Option<PathBuf>) -> Self {
+ Self {
+ text: Rope::new(),
+ path,
+ encoding: EncodingInfo::default(),
+ line_ending: LineEnding::platform_default(),
+ selection: Selection::point(0),
+ history: History::new(),
+ saved_revision: 0,
+ }
+ }
+
+ /// MJB-LLR-111, MJB-LLR-112: load `path`.
+ ///
+ /// A path that does not exist yields an empty buffer that remembers it, so
+ /// `:w` creates the file. Any other IO error is reported.
+ pub fn open(path: &Path) -> Result<Self, DocumentError> {
+ let bytes = match std::fs::read(path) {
+ Ok(b) => b,
+ // MJB-LLR-112
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
+ return Ok(Self::empty(Some(path.to_path_buf())));
+ }
+ Err(e) => return Err(DocumentError::Io(e)),
+ };
+
+ // MJB-LLR-113, MJB-LLR-118: a declared encoding is transcoded; invalid
+ // UTF-8 is rejected here rather than opened and later written back
+ // with the damage baked in.
+ let (text, encoding) = encoding::decode(&bytes)?;
+ // MJB-LLR-114 is evaluated on the raw text, before normalisation
+ // collapses CRLF and would erase the evidence.
+ let line_ending = LineEnding::detect(&text);
+ let normalized = line_ending::normalize(&text);
+
+ Ok(Self {
+ text: Rope::from_str(&normalized),
+ path: Some(path.to_path_buf()),
+ encoding,
+ line_ending,
+ selection: Selection::point(0),
+ history: History::new(),
+ saved_revision: 0,
+ })
+ }
+
+ pub fn text(&self) -> &Rope {
+ &self.text
+ }
+
+ pub fn slice(&self) -> RopeSlice<'_> {
+ self.text.slice(..)
+ }
+
+ pub fn path(&self) -> Option<&Path> {
+ self.path.as_deref()
+ }
+
+ pub fn set_path(&mut self, path: PathBuf) {
+ self.path = Some(path);
+ }
+
+ pub fn selection(&self) -> &Selection {
+ &self.selection
+ }
+
+ pub fn set_selection(&mut self, selection: Selection) {
+ self.selection = selection.clamped(self.text.slice(..));
+ }
+
+ /// Convenience: replace the primary range.
+ pub fn set_range(&mut self, range: Range) {
+ let clamped = range.clamped(self.text.slice(..));
+ self.selection.set_primary(clamped);
+ }
+
+ pub fn range(&self) -> Range {
+ self.selection.primary()
+ }
+
+ /// MJB-LLR-116: whether the buffer differs from the file on disk.
+ ///
+ /// Derived by comparing the current history revision against the one the
+ /// file was written at, so undoing back to the saved state reports clean.
+ pub fn is_modified(&self) -> bool {
+ self.history.revision() != self.saved_revision
+ }
+
+ pub fn line_ending(&self) -> LineEnding {
+ self.line_ending
+ }
+
+ pub fn len_lines(&self) -> usize {
+ self.text.len_lines(LINE_TYPE)
+ }
+
+ pub fn history_mut(&mut self) -> &mut History {
+ &mut self.history
+ }
+
+ /// MJB-LLR-117: apply `transaction`, recording its inverse for undo.
+ pub fn apply(&mut self, transaction: &Transaction) -> Result<(), DocumentError> {
+ // The inverse must be computed against the pre-change rope.
+ let inverse = Transaction::new(transaction.changes.invert(&self.text));
+
+ transaction.changes.apply(&mut self.text)?;
+
+ if let Some(sel) = &transaction.selection {
+ self.selection = sel.clone().clamped(self.text.slice(..));
+ } else {
+ self.selection = self.selection.clone().clamped(self.text.slice(..));
+ }
+
+ self.history.commit(transaction.clone(), inverse);
+ Ok(())
+ }
+
+ /// Apply without recording history — used to replay an undo or redo, whose
+ /// own bookkeeping is already handled by [`History`].
+ fn apply_without_history(&mut self, transaction: &Transaction) -> Result<(), DocumentError> {
+ transaction.changes.apply(&mut self.text)?;
+ if let Some(sel) = &transaction.selection {
+ self.selection = sel.clone().clamped(self.text.slice(..));
+ } else {
+ self.selection = self.selection.clone().clamped(self.text.slice(..));
+ }
+ Ok(())
+ }
+
+ /// MJB-LLR-052: revert the most recent change. `false` if there was none.
+ pub fn undo(&mut self) -> Result<bool, DocumentError> {
+ let Some(t) = self.history.undo().cloned() else {
+ return Ok(false);
+ };
+ self.apply_without_history(&t)?;
+ Ok(true)
+ }
+
+ /// MJB-LLR-053: reapply the most recently reverted change.
+ pub fn redo(&mut self) -> Result<bool, DocumentError> {
+ let Some(t) = self.history.redo().cloned() else {
+ return Ok(false);
+ };
+ self.apply_without_history(&t)?;
+ Ok(true)
+ }
+
+ /// MJB-LLR-115: the document as it should appear on disk.
+ ///
+ /// Assembles the text once. Chaining `to_string` → `with_final_newline` →
+ /// `apply` → `encode` would copy the whole document at each step; the
+ /// terminator rewrite and the final newline are folded into a single pass
+ /// so only the encode step copies, and only when the encoding is not the
+ /// UTF-8 the rope already holds.
+ pub fn encode(&self, insert_final_newline: bool) -> Vec<u8> {
+ let ending = self.line_ending.as_str();
+ let needs_rewrite = self.line_ending != LineEnding::Lf;
+
+ let mut text = String::with_capacity(self.text.len() + 1);
+ for chunk in self.text.chunks() {
+ if needs_rewrite {
+ // The rope stores LF only, so a plain split is sufficient.
+ let mut parts = chunk.split('\n');
+ if let Some(first) = parts.next() {
+ text.push_str(first);
+ }
+ for part in parts {
+ text.push_str(ending);
+ text.push_str(part);
+ }
+ } else {
+ text.push_str(chunk);
+ }
+ }
+
+ if insert_final_newline && !text.is_empty() && !text.ends_with(ending) {
+ text.push_str(ending);
+ }
+
+ encoding::encode(&text, self.encoding)
+ }
+
+ /// MJB-LLR-137: write to the associated path and mark it clean.
+ pub fn save(&mut self, force: bool, insert_final_newline: bool) -> Result<(), DocumentError> {
+ let path = self.path.clone().ok_or(SaveError::NoPath)?;
+ let bytes = self.encode(insert_final_newline);
+ save::write_atomic(&path, &bytes, force)?;
+ self.saved_revision = self.history.revision();
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::buffer::selection::Range;
+
+ fn doc(text: &str) -> Document {
+ let mut d = Document::empty(None);
+ d.text = Rope::from_str(text);
+ d
+ }
+
+ #[test]
+ fn mjb_llr_112_missing_file_yields_empty_buffer_that_remembers_the_path() {
+ let d = tempfile::tempdir().unwrap();
+ let p = d.path().join("not-created-yet.txt");
+ let doc = Document::open(&p).unwrap();
+ assert_eq!(doc.text().len(), 0);
+ assert_eq!(doc.path(), Some(p.as_path()));
+ assert!(!doc.is_modified());
+ }
+
+ #[test]
+ fn mjb_llr_111_loads_contents() {
+ let d = tempfile::tempdir().unwrap();
+ let p = d.path().join("f.txt");
+ std::fs::write(&p, "hello\nworld\n").unwrap();
+ let doc = Document::open(&p).unwrap();
+ assert_eq!(doc.text().to_string(), "hello\nworld\n");
+ }
+
+ #[test]
+ fn mjb_llr_114_crlf_is_detected_and_normalized_for_storage() {
+ let d = tempfile::tempdir().unwrap();
+ let p = d.path().join("crlf.txt");
+ std::fs::write(&p, "a\r\nb\r\n").unwrap();
+
+ let doc = Document::open(&p).unwrap();
+ assert_eq!(doc.line_ending(), LineEnding::Crlf);
+ assert_eq!(doc.text().to_string(), "a\nb\n", "stored as LF");
+ }
+
+ /// MJB-LLR-115, MJB-HLR-003: a CRLF file saved back is still CRLF.
+ #[test]
+ fn mjb_llr_115_crlf_round_trips_through_save() {
+ let d = tempfile::tempdir().unwrap();
+ let p = d.path().join("crlf.txt");
+ std::fs::write(&p, "a\r\nb\r\n").unwrap();
+
+ let mut doc = Document::open(&p).unwrap();
+ doc.save(false, true).unwrap();
+ assert_eq!(std::fs::read(&p).unwrap(), b"a\r\nb\r\n");
+ }
+
+ /// MJB-LLR-118: a file that is not valid UTF-8 is refused, and the file on
+ /// disk is left exactly as it was.
+ #[test]
+ fn mjb_llr_118_invalid_utf8_file_is_refused() {
+ let d = tempfile::tempdir().unwrap();
+ let p = d.path().join("bad.bin");
+ let original = [b'a', 0xFF, b'b'];
+ std::fs::write(&p, original).unwrap();
+
+ let err = Document::open(&p).expect_err("must refuse to open");
+ assert!(matches!(err, DocumentError::Decode(_)), "got {err:?}");
+ assert!(
+ err.to_string().contains("UTF-8"),
+ "message must explain why, got: {err}"
+ );
+ assert_eq!(
+ std::fs::read(&p).unwrap(),
+ original,
+ "a refused open must not touch the file"
+ );
+ }
+
+ /// MJB-LLR-113: a BOM-declared non-UTF-8 file still opens.
+ #[test]
+ fn mjb_llr_113_declared_utf16_file_opens() {
+ let d = tempfile::tempdir().unwrap();
+ let p = d.path().join("u16.txt");
+ // UTF-16LE BOM + "hi".
+ std::fs::write(&p, [0xFF, 0xFE, b'h', 0x00, b'i', 0x00]).unwrap();
+
+ let doc = Document::open(&p).expect("a declared encoding must open");
+ assert_eq!(doc.text().to_string(), "hi");
+ }
+
+ #[test]
+ fn mjb_llr_116_modified_flag_lifecycle() {
+ let d = tempfile::tempdir().unwrap();
+ let p = d.path().join("f.txt");
+ std::fs::write(&p, "abc").unwrap();
+
+ let mut doc = Document::open(&p).unwrap();
+ assert!(!doc.is_modified(), "freshly opened");
+
+ let t = Transaction::insert(doc.text(), doc.selection(), "X");
+ doc.apply(&t).unwrap();
+ assert!(doc.is_modified(), "set by an applied transaction");
+
+ doc.save(false, false).unwrap();
+ assert!(!doc.is_modified(), "cleared by a successful write");
+ }
+
+ /// MJB-LLR-137: a successful write clears `modified`; a failed one must
+ /// not, or the user would be told their unsaved work is safe.
+ #[test]
+ fn mjb_llr_137_failed_write_leaves_modified_set() {
+ let mut doc = doc("content");
+ // No path: the save cannot succeed.
+ let t = Transaction::insert(doc.text(), doc.selection(), "X");
+ doc.apply(&t).unwrap();
+ assert!(doc.is_modified());
+
+ assert!(doc.save(false, true).is_err());
+ assert!(
+ doc.is_modified(),
+ "a failed write must not clear the modified flag"
+ );
+ }
+
+ /// MJB-LLR-116: undoing back to the saved state reports the buffer clean.
+ /// A latched flag would keep claiming unsaved changes for content that is
+ /// byte-identical to the file.
+ #[test]
+ fn mjb_llr_116_undo_back_to_saved_state_is_clean() {
+ let d = tempfile::tempdir().unwrap();
+ let p = d.path().join("f.txt");
+ std::fs::write(&p, "abc").unwrap();
+ let mut doc = Document::open(&p).unwrap();
+
+ doc.save(false, false).unwrap();
+ assert!(!doc.is_modified());
+
+ let t = Transaction::insert(doc.text(), doc.selection(), "X");
+ doc.apply(&t).unwrap();
+ assert!(doc.is_modified(), "an edit dirties the buffer");
+
+ doc.undo().unwrap();
+ assert!(
+ !doc.is_modified(),
+ "undoing back to the saved content must report clean"
+ );
+
+ doc.redo().unwrap();
+ assert!(doc.is_modified(), "redoing dirties it again");
+ }
+
+ /// MJB-LLR-116: same history depth, different content, must stay dirty.
+ #[test]
+ fn mjb_llr_116_divergent_edit_at_the_same_depth_stays_modified() {
+ let d = tempfile::tempdir().unwrap();
+ let p = d.path().join("f.txt");
+ std::fs::write(&p, "abc").unwrap();
+ let mut doc = Document::open(&p).unwrap();
+
+ let t = Transaction::insert(doc.text(), doc.selection(), "X");
+ doc.apply(&t).unwrap();
+ doc.save(false, false).unwrap();
+ assert!(!doc.is_modified());
+
+ doc.undo().unwrap();
+ // A *different* edit, returning to the same history depth.
+ let t = Transaction::insert(doc.text(), doc.selection(), "Y");
+ doc.apply(&t).unwrap();
+
+ assert!(
+ doc.is_modified(),
+ "content differs from the file despite equal history depth"
+ );
+ assert_eq!(doc.text().to_string(), "Yabc");
+ }
+
+ #[test]
+ fn mjb_llr_137_successful_write_clears_modified() {
+ let d = tempfile::tempdir().unwrap();
+ let p = d.path().join("f.txt");
+ let mut doc = Document::open(&p).unwrap();
+
+ let t = Transaction::insert(doc.text(), doc.selection(), "hello");
+ doc.apply(&t).unwrap();
+ assert!(doc.is_modified());
+
+ doc.save(false, true).unwrap();
+ assert!(!doc.is_modified());
+ assert_eq!(std::fs::read_to_string(&p).unwrap(), "hello\n");
+ }
+
+ #[test]
+ fn mjb_llr_117_apply_updates_text_and_records_history() {
+ let mut doc = doc("hello");
+ let t = Transaction::change(doc.text(), [(0, 5, Some("goodbye".into()))]);
+ doc.apply(&t).unwrap();
+ assert_eq!(doc.text().to_string(), "goodbye");
+ assert!(doc.history.can_undo());
+ }
+
+ #[test]
+ fn mjb_llr_052_undo_restores_previous_text() {
+ let mut doc = doc("hello");
+ let t = Transaction::change(doc.text(), [(0, 5, Some("goodbye".into()))]);
+ doc.apply(&t).unwrap();
+
+ assert!(doc.undo().unwrap());
+ assert_eq!(doc.text().to_string(), "hello");
+ }
+
+ #[test]
+ fn mjb_llr_053_redo_reapplies() {
+ let mut doc = doc("hello");
+ let t = Transaction::change(doc.text(), [(0, 5, Some("goodbye".into()))]);
+ doc.apply(&t).unwrap();
+ doc.undo().unwrap();
+
+ assert!(doc.redo().unwrap());
+ assert_eq!(doc.text().to_string(), "goodbye");
+ }
+
+ /// MJB-LLR-052: undoing past the start is a no-op, not an error.
+ #[test]
+ fn mjb_llr_052_undo_past_history_start_is_a_noop() {
+ let mut doc = doc("hello");
+ assert!(!doc.undo().unwrap(), "nothing to undo");
+ assert_eq!(doc.text().to_string(), "hello");
+ assert!(!doc.undo().unwrap(), "still nothing, still safe");
+ }
+
+ #[test]
+ fn mjb_llr_053_redo_past_end_is_a_noop() {
+ let mut doc = doc("hello");
+ assert!(!doc.redo().unwrap());
+ assert_eq!(doc.text().to_string(), "hello");
+ }
+
+ #[test]
+ fn multiple_undo_redo_cycles_are_stable() {
+ let mut doc = doc("");
+ for c in ["a", "b", "c"] {
+ let t = Transaction::insert(doc.text(), doc.selection(), c);
+ doc.apply(&t).unwrap();
+ let end = doc.text().len();
+ doc.set_range(Range::point(end));
+ }
+ assert_eq!(doc.text().to_string(), "abc");
+
+ for _ in 0..3 {
+ assert!(doc.undo().unwrap());
+ }
+ assert_eq!(doc.text().to_string(), "");
+
+ for _ in 0..3 {
+ assert!(doc.redo().unwrap());
+ }
+ assert_eq!(doc.text().to_string(), "abc");
+ }
+
+ #[test]
+ fn mjb_llr_185_final_newline_added_on_encode_when_requested() {
+ let doc = doc("no trailing newline");
+ assert!(doc.encode(true).ends_with(b"\n"));
+ assert!(!doc.encode(false).ends_with(b"\n"));
+ }
+
+ #[test]
+ fn mjb_llr_185_empty_document_encodes_empty() {
+ let doc = doc("");
+ assert!(doc.encode(true).is_empty(), "must not invent a newline");
+ }
+
+ #[test]
+ fn saving_without_a_path_is_an_error_not_a_panic() {
+ let mut doc = doc("x");
+ assert!(doc.save(false, true).is_err());
+ }
+}
diff --git a/src/buffer/encoding.rs b/src/buffer/encoding.rs
new file mode 100644
index 0000000..5cd852f
--- /dev/null
+++ b/src/buffer/encoding.rs
@@ -0,0 +1,272 @@
+//! Character encoding detection and transcoding (MJB-HLR-002).
+//!
+//! Two rules, the second an exception to the first:
+//!
+//! 1. **A file whose encoding a byte order mark declares is transcoded.** The
+//! encoding is known, so its bytes round-trip through load and save.
+//! 2. **UTF-8 is strict.** A file assumed or declared to be UTF-8 that holds an
+//! invalid byte sequence is *rejected*, not repaired.
+//!
+//! Rule 2 exists because substitution is lossy in a way the user cannot see:
+//! replacing a bad byte with U+FFFD and then saving writes the replacement
+//! character over their data. For a declared encoding we can at least reproduce
+//! what we read; for malformed UTF-8 we cannot, so refusing to open is the only
+//! non-destructive answer. See MJB-DR-001.
+
+use encoding_rs::{Encoding, UTF_8, UTF_16BE, UTF_16LE};
+
+/// MJB-LLR-118: why a file could not be decoded.
+#[derive(Debug, thiserror::Error, PartialEq, Eq)]
+pub enum DecodeError {
+ #[error(
+ "not valid UTF-8 (invalid byte sequence at offset {valid_up_to}); \
+ mojibake edits text, and repairing the bytes would destroy them on save"
+ )]
+ InvalidUtf8 { valid_up_to: usize },
+}
+
+/// The encoding a document was loaded with, plus whether it carried a BOM.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct EncodingInfo {
+ pub encoding: &'static Encoding,
+ pub has_bom: bool,
+}
+
+impl Default for EncodingInfo {
+ fn default() -> Self {
+ Self {
+ encoding: UTF_8,
+ has_bom: false,
+ }
+ }
+}
+
+/// MJB-LLR-110: recognise a byte order mark, returning the encoding it implies
+/// and the mark's length in bytes.
+pub fn detect_bom(bytes: &[u8]) -> Option<(&'static Encoding, usize)> {
+ if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) {
+ Some((UTF_8, 3))
+ } else if bytes.starts_with(&[0xFF, 0xFE]) {
+ Some((UTF_16LE, 2))
+ } else if bytes.starts_with(&[0xFE, 0xFF]) {
+ Some((UTF_16BE, 2))
+ } else {
+ None
+ }
+}
+
+/// The byte order mark for `encoding`, if it has one.
+pub fn bom_bytes(encoding: &'static Encoding) -> &'static [u8] {
+ if encoding == UTF_8 {
+ &[0xEF, 0xBB, 0xBF]
+ } else if encoding == UTF_16LE {
+ &[0xFF, 0xFE]
+ } else if encoding == UTF_16BE {
+ &[0xFE, 0xFF]
+ } else {
+ &[]
+ }
+}
+
+/// MJB-LLR-111, MJB-LLR-113, MJB-LLR-118: decode `bytes` to a `String`.
+///
+/// UTF-8 — whether declared by a BOM or merely assumed — is validated strictly
+/// and rejected when malformed. Other BOM-declared encodings are transcoded.
+pub fn decode(bytes: &[u8]) -> Result<(String, EncodingInfo), DecodeError> {
+ let (encoding, has_bom, body) = match detect_bom(bytes) {
+ Some((encoding, bom_len)) => (encoding, true, &bytes[bom_len..]),
+ None => (UTF_8, false, bytes),
+ };
+
+ let text = if encoding == UTF_8 {
+ // MJB-LLR-118: the exception. `from_utf8` reports exactly how far the
+ // input was valid, which makes the diagnostic actionable.
+ std::str::from_utf8(body)
+ .map_err(|e| DecodeError::InvalidUtf8 {
+ valid_up_to: e.valid_up_to(),
+ })?
+ .to_owned()
+ } else {
+ // MJB-LLR-113: a declared non-UTF-8 encoding is transcoded. Its bytes
+ // round-trip on save, so any substitution here is reproducible.
+ encoding.decode_without_bom_handling(body).0.into_owned()
+ };
+
+ Ok((text, EncodingInfo { encoding, has_bom }))
+}
+
+/// MJB-LLR-115: encode `text` back to bytes, re-emitting the BOM when the
+/// document was loaded with one.
+///
+/// Total by construction: `text` is a `str`, and a document can only hold text
+/// that [`decode`] accepted, so there is nothing here that can fail.
+///
+/// UTF-16 is encoded here by hand rather than through `encoding_rs`.
+/// `Encoding::encode` is deliberately asymmetric: it decodes UTF-16 but will
+/// not *encode* to it, silently substituting UTF-8 instead. Delegating would
+/// therefore write UTF-8 bytes beneath a UTF-16 BOM and corrupt the file.
+/// See MJB-DR-007.
+pub fn encode(text: &str, info: EncodingInfo) -> Vec<u8> {
+ let mut out = Vec::with_capacity(text.len() + 3);
+ if info.has_bom {
+ out.extend_from_slice(bom_bytes(info.encoding));
+ }
+
+ if info.encoding == UTF_16LE {
+ for unit in text.encode_utf16() {
+ out.extend_from_slice(&unit.to_le_bytes());
+ }
+ } else if info.encoding == UTF_16BE {
+ for unit in text.encode_utf16() {
+ out.extend_from_slice(&unit.to_be_bytes());
+ }
+ } else {
+ let (bytes, _, _) = info.encoding.encode(text);
+ out.extend_from_slice(&bytes);
+ }
+
+ out
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn mjb_llr_110_detects_each_bom() {
+ assert_eq!(detect_bom(&[0xEF, 0xBB, 0xBF, b'a']), Some((UTF_8, 3)));
+ assert_eq!(detect_bom(&[0xFF, 0xFE, b'a', 0]), Some((UTF_16LE, 2)));
+ assert_eq!(detect_bom(&[0xFE, 0xFF, 0, b'a']), Some((UTF_16BE, 2)));
+ assert_eq!(detect_bom(b"plain"), None);
+ assert_eq!(detect_bom(b""), None, "empty input must not index past end");
+ }
+
+ #[test]
+ fn mjb_llr_111_plain_utf8_round_trips() {
+ let (text, info) = decode("hello 文字化け".as_bytes()).unwrap();
+ assert_eq!(text, "hello 文字化け");
+ assert!(!info.has_bom);
+ assert_eq!(encode(&text, info), "hello 文字化け".as_bytes());
+ }
+
+ /// MJB-LLR-115, MJB-DR-001: a BOM present on load reappears on save.
+ #[test]
+ fn mjb_llr_115_bom_round_trips() {
+ let mut input = vec![0xEF, 0xBB, 0xBF];
+ input.extend_from_slice(b"hi");
+
+ let (text, info) = decode(&input).unwrap();
+ assert_eq!(text, "hi", "BOM is stripped from the buffer contents");
+ assert!(info.has_bom);
+ assert_eq!(encode(&text, info), input, "and re-emitted on write");
+ }
+
+ /// MJB-LLR-115, MJB-DR-007: UTF-16 must survive a load/save cycle.
+ ///
+ /// Regression guard: `encoding_rs::Encoding::encode` substitutes UTF-8 for
+ /// UTF-16 rather than failing, so delegating to it here would write UTF-8
+ /// bytes under a UTF-16 BOM and corrupt the file.
+ #[test]
+ fn mjb_llr_115_utf16le_round_trips() {
+ // UTF-16LE BOM followed by "hi".
+ let input = vec![0xFF, 0xFE, b'h', 0x00, b'i', 0x00];
+ let (text, info) = decode(&input).unwrap();
+ assert_eq!(text, "hi");
+ assert_eq!(info.encoding, UTF_16LE);
+ assert_eq!(encode(&text, info), input, "must not degrade to UTF-8");
+ }
+
+ #[test]
+ fn mjb_llr_115_utf16be_round_trips() {
+ let input = vec![0xFE, 0xFF, 0x00, b'h', 0x00, b'i'];
+ let (text, info) = decode(&input).unwrap();
+ assert_eq!(text, "hi");
+ assert_eq!(info.encoding, UTF_16BE);
+ assert_eq!(encode(&text, info), input);
+ }
+
+ #[test]
+ fn mjb_llr_115_utf16_handles_non_ascii_and_surrogates() {
+ // 文 is BMP; 𝄞 (U+1D11E) needs a surrogate pair in UTF-16.
+ let original = "文𝄞";
+ let info = EncodingInfo {
+ encoding: UTF_16LE,
+ has_bom: true,
+ };
+ let bytes = encode(original, info);
+ let (back, _) = decode(&bytes).unwrap();
+ assert_eq!(back, original);
+ }
+
+ /// MJB-LLR-113: a *declared* non-UTF-8 encoding is transcoded, not
+ /// rejected. An unpaired surrogate is repaired, and that repair is
+ /// reproducible because the encoding is known.
+ #[test]
+ fn mjb_llr_113_declared_utf16_is_transcoded_not_rejected() {
+ // UTF-16LE BOM, then a lone high surrogate (0xD800) — not valid UTF-16.
+ let input = vec![0xFF, 0xFE, 0x00, 0xD8, b'a', 0x00];
+ let (text, info) = decode(&input).expect("a declared encoding must not be rejected");
+ assert_eq!(info.encoding, UTF_16LE);
+ assert!(
+ text.contains('\u{FFFD}'),
+ "the unpaired surrogate becomes U+FFFD, got {text:?}"
+ );
+ }
+
+ /// MJB-LLR-118: the UTF-8 exception. Invalid UTF-8 is rejected outright
+ /// rather than repaired, because a repair would be written back over the
+ /// user's data on save.
+ #[test]
+ fn mjb_llr_118_invalid_utf8_is_rejected() {
+ // 0xFF is never valid in UTF-8.
+ let err = decode(&[b'a', 0xFF, b'b']).unwrap_err();
+ assert_eq!(err, DecodeError::InvalidUtf8 { valid_up_to: 1 });
+ }
+
+ #[test]
+ fn mjb_llr_118_rejection_names_the_offset() {
+ let err = decode(&[b'h', b'i', 0x80]).unwrap_err();
+ assert_eq!(err, DecodeError::InvalidUtf8 { valid_up_to: 2 });
+ assert!(
+ err.to_string().contains('2'),
+ "the message must locate the bad byte, got: {err}"
+ );
+ }
+
+ /// A truncated multi-byte character is invalid UTF-8 too.
+ #[test]
+ fn mjb_llr_118_truncated_multibyte_char_is_rejected() {
+ // 文 is E6 96 87; drop the last byte.
+ let err = decode(&[0xE6, 0x96]).unwrap_err();
+ assert_eq!(err, DecodeError::InvalidUtf8 { valid_up_to: 0 });
+ }
+
+ /// MJB-LLR-118: a UTF-8 *BOM* does not license invalid bytes after it.
+ #[test]
+ fn mjb_llr_118_declared_utf8_is_strict_too() {
+ let input = vec![0xEF, 0xBB, 0xBF, b'a', 0xFF];
+ let err = decode(&input).unwrap_err();
+ assert_eq!(
+ err,
+ DecodeError::InvalidUtf8 { valid_up_to: 1 },
+ "offset is measured past the BOM"
+ );
+ }
+
+ /// Valid multi-byte UTF-8 must not be mistaken for invalid.
+ #[test]
+ fn mjb_llr_118_valid_multibyte_utf8_is_accepted() {
+ for s in ["文字化け", "e\u{0301}", "𝄞", "café", "", "\u{FFFD}"] {
+ let (text, _) = decode(s.as_bytes())
+ .unwrap_or_else(|e| panic!("{s:?} must decode, got {e}"));
+ assert_eq!(text, s);
+ }
+ }
+
+ #[test]
+ fn mjb_llr_113_empty_input_decodes_to_empty() {
+ let (text, info) = decode(b"").unwrap();
+ assert_eq!(text, "");
+ assert!(!info.has_bom);
+ }
+}
diff --git a/src/buffer/grapheme.rs b/src/buffer/grapheme.rs
new file mode 100644
index 0000000..8670853
--- /dev/null
+++ b/src/buffer/grapheme.rs
@@ -0,0 +1,325 @@
+//! Grapheme cluster boundaries and display width over a rope.
+//!
+//! Under byte indexing an offset can land inside a character or inside a
+//! grapheme cluster, so every cursor position the user can observe is snapped
+//! to a grapheme boundary here. See MJB-DR-002.
+//!
+//! `unicode_segmentation::GraphemeCursor` works over `&str` fragments and asks
+//! for more context when a cluster straddles a fragment edge; ropey's
+//! `chunk(byte_idx) -> (&str, chunk_start)` supplies exactly that, so clusters
+//! spanning chunk boundaries resolve correctly (MJB-LLR-022).
+
+use std::borrow::Cow;
+
+use ropey::RopeSlice;
+use unicode_segmentation::{GraphemeCursor, GraphemeIncomplete};
+use unicode_width::UnicodeWidthStr;
+
+/// Columns a tab advances to. Fixed rather than configurable; a configurable
+/// tab stop would be a new requirement, not a derived one.
+pub const TAB_WIDTH: usize = 4;
+
+/// MJB-LLR-020: byte offset of the grapheme boundary preceding `byte_idx`,
+/// or 0 when there is none.
+pub fn prev_grapheme_boundary(slice: RopeSlice, byte_idx: usize) -> usize {
+ let len = slice.len();
+ let byte_idx = slice.floor_char_boundary(byte_idx.min(len));
+ if byte_idx == 0 {
+ return 0;
+ }
+
+ let mut cursor = GraphemeCursor::new(byte_idx, len, true);
+ let (mut chunk, mut chunk_start) = slice.chunk(byte_idx);
+
+ loop {
+ match cursor.prev_boundary(chunk, chunk_start) {
+ Ok(Some(n)) => return n,
+ Ok(None) => return 0,
+ Err(GraphemeIncomplete::PrevChunk) => {
+ // Step back one chunk and retry.
+ let (c, s) = slice.chunk(chunk_start.saturating_sub(1));
+ chunk = c;
+ chunk_start = s;
+ }
+ Err(GraphemeIncomplete::PreContext(n)) => {
+ let (ctx, ctx_start) = slice.chunk(n.saturating_sub(1));
+ cursor.provide_context(ctx, ctx_start);
+ }
+ // The remaining variants cannot arise from prev_boundary with a
+ // cursor built over the whole slice; treat defensively as "no
+ // boundary found" rather than panicking (MJB-HLR-018).
+ Err(_) => return 0,
+ }
+ }
+}
+
+/// MJB-LLR-021: byte offset of the grapheme boundary following `byte_idx`,
+/// or `slice.len()` when there is none.
+pub fn next_grapheme_boundary(slice: RopeSlice, byte_idx: usize) -> usize {
+ let len = slice.len();
+ let byte_idx = slice.floor_char_boundary(byte_idx.min(len));
+ if byte_idx >= len {
+ return len;
+ }
+
+ let mut cursor = GraphemeCursor::new(byte_idx, len, true);
+ let (mut chunk, mut chunk_start) = slice.chunk(byte_idx);
+
+ loop {
+ match cursor.next_boundary(chunk, chunk_start) {
+ Ok(Some(n)) => return n,
+ Ok(None) => return len,
+ Err(GraphemeIncomplete::NextChunk) => {
+ let next_start = chunk_start + chunk.len();
+ if next_start >= len {
+ return len;
+ }
+ let (c, s) = slice.chunk(next_start);
+ chunk = c;
+ chunk_start = s;
+ }
+ Err(GraphemeIncomplete::PreContext(n)) => {
+ let (ctx, ctx_start) = slice.chunk(n.saturating_sub(1));
+ cursor.provide_context(ctx, ctx_start);
+ }
+ Err(_) => return len,
+ }
+ }
+}
+
+/// MJB-LLR-023: whether `byte_idx` lies on a grapheme cluster boundary.
+pub fn is_grapheme_boundary(slice: RopeSlice, byte_idx: usize) -> bool {
+ let len = slice.len();
+ if byte_idx > len || !slice.is_char_boundary(byte_idx) {
+ return false;
+ }
+ if byte_idx == 0 || byte_idx == len {
+ return true;
+ }
+
+ let mut cursor = GraphemeCursor::new(byte_idx, len, true);
+ let (chunk, chunk_start) = slice.chunk(byte_idx);
+
+ loop {
+ match cursor.is_boundary(chunk, chunk_start) {
+ Ok(b) => return b,
+ Err(GraphemeIncomplete::PreContext(n)) => {
+ let (ctx, ctx_start) = slice.chunk(n.saturating_sub(1));
+ cursor.provide_context(ctx, ctx_start);
+ }
+ Err(_) => return false,
+ }
+ }
+}
+
+/// The text in `byte_range` without allocating when it lies in one rope chunk.
+///
+/// Rendering asks for every grapheme on every visible line each frame, so the
+/// obvious `slice.chunks().collect::<String>()` would allocate once per cell
+/// per frame. A grapheme spans a chunk boundary only rarely, and only then is
+/// a copy made.
+pub fn grapheme_str(slice: RopeSlice<'_>, byte_range: std::ops::Range<usize>) -> Cow<'_, str> {
+ let sub = slice.slice(byte_range);
+ match sub.as_str() {
+ Some(s) => Cow::Borrowed(s),
+ None => Cow::Owned(sub.chunks().collect()),
+ }
+}
+
+/// MJB-LLR-024: terminal display width of one grapheme cluster.
+///
+/// A tab is width-dependent on where it starts, so callers pass the column it
+/// begins at. Control characters render as nothing and count zero.
+pub fn grapheme_width(grapheme: &str, at_column: usize) -> usize {
+ if grapheme == "\t" {
+ return TAB_WIDTH - (at_column % TAB_WIDTH);
+ }
+ if grapheme.chars().all(|c| c.is_control()) {
+ return 0;
+ }
+ UnicodeWidthStr::width(grapheme)
+}
+
+/// MJB-LLR-025: display column of `byte_idx` within `line`, accumulating
+/// grapheme widths rather than counting bytes.
+pub fn display_column(line: RopeSlice, byte_idx: usize) -> usize {
+ let limit = line.floor_char_boundary(byte_idx.min(line.len()));
+ let mut column = 0;
+ let mut pos = 0;
+
+ while pos < limit {
+ let next = next_grapheme_boundary(line, pos);
+ if next <= pos {
+ break;
+ }
+ let g = grapheme_str(line, pos..next.min(limit));
+ column += grapheme_width(&g, column);
+ pos = next;
+ }
+ column
+}
+
+/// Inverse of [`display_column`]: the byte offset within `line` whose display
+/// column is nearest to but not beyond `target_column`. Used to preserve the
+/// visual column across vertical motion (MJB-LLR-064).
+pub fn byte_at_display_column(line: RopeSlice, target_column: usize) -> usize {
+ let len = line.len();
+ let mut column = 0;
+ let mut pos = 0;
+
+ while pos < len && column < target_column {
+ let next = next_grapheme_boundary(line, pos);
+ if next <= pos {
+ break;
+ }
+ let g = grapheme_str(line, pos..next);
+ // A line terminator is not a landing position.
+ if g.starts_with('\n') || g.starts_with('\r') {
+ break;
+ }
+ column += grapheme_width(&g, column);
+ if column > target_column {
+ break;
+ }
+ pos = next;
+ }
+ pos
+}
+
+#[cfg(test)]
+mod tests {
+ use ropey::Rope;
+
+ use super::*;
+
+ #[test]
+ fn mjb_llr_020_prev_boundary_saturates_at_zero() {
+ let r = Rope::from_str("abc");
+ assert_eq!(prev_grapheme_boundary(r.slice(..), 0), 0);
+ assert_eq!(prev_grapheme_boundary(r.slice(..), 1), 0);
+ assert_eq!(prev_grapheme_boundary(r.slice(..), 3), 2);
+ }
+
+ #[test]
+ fn mjb_llr_021_next_boundary_saturates_at_end() {
+ let r = Rope::from_str("abc");
+ assert_eq!(next_grapheme_boundary(r.slice(..), 3), 3);
+ assert_eq!(next_grapheme_boundary(r.slice(..), 0), 1);
+ // Beyond the end must clamp rather than panic.
+ assert_eq!(next_grapheme_boundary(r.slice(..), 99), 3);
+ }
+
+ #[test]
+ fn mjb_llr_021_multibyte_advances_whole_char() {
+ // 文 is 3 bytes; a boundary must not land inside it.
+ let r = Rope::from_str("文字化け");
+ assert_eq!(next_grapheme_boundary(r.slice(..), 0), 3);
+ assert_eq!(prev_grapheme_boundary(r.slice(..), 3), 0);
+ }
+
+ #[test]
+ fn mjb_llr_020_combining_mark_is_one_cluster() {
+ // "e" + U+0301 COMBINING ACUTE ACCENT is a single grapheme cluster.
+ let r = Rope::from_str("e\u{0301}x");
+ assert_eq!(next_grapheme_boundary(r.slice(..), 0), 3);
+ assert_eq!(prev_grapheme_boundary(r.slice(..), 3), 0);
+ }
+
+ /// MJB-LLR-022: boundaries must resolve identically whether or not the
+ /// cluster straddles a rope chunk edge.
+ ///
+ /// A rope large enough to hold many chunks is built from multi-byte
+ /// characters, then every boundary is walked and compared against the
+ /// contiguous `&str` answer.
+ #[test]
+ fn mjb_llr_022_boundaries_resolve_across_chunk_edges() {
+ // Large enough to force ropey to split into multiple chunks.
+ let source: String = "文字化けe\u{0301}x".repeat(4000);
+ let r = Rope::from_str(&source);
+ let s = r.slice(..);
+ assert!(
+ s.chunks().count() > 1,
+ "test is meaningless without multiple chunks"
+ );
+
+ // Walk forward over the whole rope, comparing to unicode-segmentation
+ // over the contiguous string.
+ use unicode_segmentation::UnicodeSegmentation;
+ let expected: Vec<usize> = source
+ .grapheme_indices(true)
+ .map(|(i, _)| i)
+ .chain(std::iter::once(source.len()))
+ .collect();
+
+ let mut got = vec![0usize];
+ let mut pos = 0;
+ while pos < s.len() {
+ let next = next_grapheme_boundary(s, pos);
+ assert!(next > pos, "must make progress at byte {pos}");
+ got.push(next);
+ pos = next;
+ }
+ assert_eq!(got, expected, "forward boundaries must match across chunks");
+
+ // And backward, from the end.
+ let mut back = vec![s.len()];
+ let mut pos = s.len();
+ while pos > 0 {
+ let prev = prev_grapheme_boundary(s, pos);
+ assert!(prev < pos, "must make progress backward at byte {pos}");
+ back.push(prev);
+ pos = prev;
+ }
+ back.reverse();
+ assert_eq!(back, expected, "backward boundaries must match across chunks");
+ }
+
+ #[test]
+ fn mjb_llr_023_boundary_detection() {
+ let r = Rope::from_str("文a");
+ let s = r.slice(..);
+ assert!(is_grapheme_boundary(s, 0));
+ assert!(!is_grapheme_boundary(s, 1), "inside a multi-byte char");
+ assert!(is_grapheme_boundary(s, 3));
+ assert!(is_grapheme_boundary(s, 4));
+ }
+
+ #[test]
+ fn mjb_llr_024_widths() {
+ assert_eq!(grapheme_width("a", 0), 1);
+ assert_eq!(grapheme_width("文", 0), 2, "wide char occupies two columns");
+ assert_eq!(grapheme_width("\t", 0), TAB_WIDTH);
+ assert_eq!(grapheme_width("\t", 1), TAB_WIDTH - 1, "tab fills to stop");
+ assert_eq!(grapheme_width("\u{0}", 0), 0);
+ }
+
+ #[test]
+ fn mjb_llr_025_display_column_counts_width_not_bytes() {
+ let r = Rope::from_str("文字a");
+ // Byte 6 is after two wide chars: four columns, not six.
+ assert_eq!(display_column(r.slice(..), 6), 4);
+ assert_eq!(display_column(r.slice(..), 0), 0);
+ }
+
+ #[test]
+ fn mjb_llr_025_display_column_tab_expands() {
+ let r = Rope::from_str("\tx");
+ assert_eq!(display_column(r.slice(..), 1), TAB_WIDTH);
+ }
+
+ #[test]
+ fn byte_at_display_column_round_trips() {
+ let r = Rope::from_str("文字a");
+ let s = r.slice(..);
+ assert_eq!(byte_at_display_column(s, 4), 6);
+ assert_eq!(byte_at_display_column(s, 0), 0);
+ // Past the end of the line clamps to the line's length.
+ assert_eq!(byte_at_display_column(s, 99), s.len());
+ }
+
+ #[test]
+ fn byte_at_display_column_stops_before_terminator() {
+ let r = Rope::from_str("ab\n");
+ assert_eq!(byte_at_display_column(r.slice(..), 99), 2);
+ }
+}
diff --git a/src/buffer/history.rs b/src/buffer/history.rs
new file mode 100644
index 0000000..e4668fc
--- /dev/null
+++ b/src/buffer/history.rs
@@ -0,0 +1,228 @@
+//! Undo/redo history (MJB-HLR-011).
+//!
+//! Each committed edit stores the pair (forward transaction, inverse
+//! transaction). `cursor` is the number of entries currently *applied*, so
+//! entries at or beyond it have been reverted and are available to redo.
+//!
+//! Undo at the start and redo at the end are no-ops, not errors — reaching
+//! either end is ordinary use, not a fault (MJB-LLR-052, MJB-LLR-053).
+
+use super::transaction::Transaction;
+
+#[derive(Debug, Clone)]
+struct Entry {
+ forward: Transaction,
+ inverse: Transaction,
+ /// Identifies the buffer state this entry produces. Never reused.
+ id: usize,
+}
+
+#[derive(Debug, Clone, Default)]
+pub struct History {
+ entries: Vec<Entry>,
+ /// Number of entries applied; also the index of the next redo.
+ cursor: usize,
+ /// Monotonic source of entry ids.
+ ///
+ /// Deliberately *not* the same thing as `cursor`. Using stack depth to
+ /// identify a state is wrong: saving at depth 3, undoing, then making a
+ /// different edit returns to depth 3 while the content differs, so a
+ /// depth comparison would report the buffer clean when it is not.
+ next_id: usize,
+}
+
+impl History {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// MJB-LLR-051: record an applied edit.
+ ///
+ /// Anything previously undone is discarded — committing a new edit after an
+ /// undo abandons the branch that was reverted.
+ pub fn commit(&mut self, forward: Transaction, inverse: Transaction) {
+ self.entries.truncate(self.cursor);
+ self.next_id += 1;
+ self.entries.push(Entry {
+ forward,
+ inverse,
+ id: self.next_id,
+ });
+ self.cursor = self.entries.len();
+ }
+
+ /// MJB-LLR-052: the transaction that reverts the most recent edit, or
+ /// `None` when nothing remains to undo.
+ pub fn undo(&mut self) -> Option<&Transaction> {
+ if self.cursor == 0 {
+ return None;
+ }
+ self.cursor -= 1;
+ Some(&self.entries[self.cursor].inverse)
+ }
+
+ /// MJB-LLR-053: the transaction that reapplies the most recently undone
+ /// edit, or `None` when nothing remains to redo.
+ pub fn redo(&mut self) -> Option<&Transaction> {
+ if self.cursor >= self.entries.len() {
+ return None;
+ }
+ let t = &self.entries[self.cursor].forward;
+ self.cursor += 1;
+ Some(t)
+ }
+
+ /// Identifies the current buffer state.
+ ///
+ /// Zero means pristine — no edit applied. Otherwise it is the id of the
+ /// most recently applied entry. Two calls return the same value exactly
+ /// when the buffer content is the same, which is what lets "modified" be
+ /// *derived* rather than latched; see
+ /// [`super::document::Document::is_modified`].
+ pub fn revision(&self) -> usize {
+ match self.cursor {
+ 0 => 0,
+ n => self.entries[n - 1].id,
+ }
+ }
+
+ pub fn can_undo(&self) -> bool {
+ self.cursor > 0
+ }
+
+ pub fn can_redo(&self) -> bool {
+ self.cursor < self.entries.len()
+ }
+
+ pub fn len(&self) -> usize {
+ self.entries.len()
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.entries.is_empty()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use ropey::Rope;
+
+ use super::*;
+ use crate::buffer::transaction::Transaction;
+
+ fn edit(text: &str, from: usize, to: usize, ins: Option<&str>) -> (Transaction, Transaction) {
+ let r = Rope::from_str(text);
+ let t = Transaction::change(&r, [(from, to, ins.map(str::to_owned))]);
+ let inv = Transaction::new(t.changes.invert(&r));
+ (t, inv)
+ }
+
+ #[test]
+ fn mjb_llr_052_undo_past_start_is_a_noop() {
+ let mut h = History::new();
+ assert!(h.undo().is_none());
+ assert!(!h.can_undo());
+ // Repeated attempts must stay safe, not underflow the cursor.
+ assert!(h.undo().is_none());
+ assert!(h.undo().is_none());
+ }
+
+ #[test]
+ fn mjb_llr_053_redo_past_end_is_a_noop() {
+ let mut h = History::new();
+ let (f, i) = edit("abc", 0, 1, None);
+ h.commit(f, i);
+ assert!(h.redo().is_none(), "nothing has been undone yet");
+ assert!(!h.can_redo());
+ }
+
+ #[test]
+ fn mjb_llr_051_commit_then_undo_then_redo() {
+ let mut h = History::new();
+ let (f, i) = edit("abc", 0, 1, None);
+ h.commit(f, i);
+
+ assert!(h.can_undo());
+ assert!(h.undo().is_some());
+ assert!(!h.can_undo());
+ assert!(h.can_redo());
+ assert!(h.redo().is_some());
+ assert!(!h.can_redo());
+ }
+
+ #[test]
+ fn mjb_llr_051_commit_after_undo_discards_the_redo_branch() {
+ let mut h = History::new();
+ let (f1, i1) = edit("abc", 0, 1, None);
+ let (f2, i2) = edit("bc", 0, 1, None);
+ h.commit(f1, i1);
+ h.commit(f2, i2);
+
+ h.undo();
+ assert!(h.can_redo());
+
+ let (f3, i3) = edit("bc", 1, 2, None);
+ h.commit(f3, i3);
+ assert!(!h.can_redo(), "the undone branch must be discarded");
+ assert_eq!(h.len(), 2);
+ }
+
+ /// MJB-LLR-116: a revision identifies *content*, not stack depth.
+ ///
+ /// Regression guard for the trap this replaced: save at depth 2, undo, then
+ /// make a different edit. The cursor returns to 2, but the buffer no longer
+ /// matches what was saved, so the revision must differ.
+ #[test]
+ fn mjb_llr_116_revision_is_not_stack_depth() {
+ let mut h = History::new();
+ let (f1, i1) = edit("abcdef", 0, 1, None);
+ let (f2, i2) = edit("bcdef", 0, 1, None);
+ h.commit(f1, i1);
+ h.commit(f2, i2);
+
+ let saved = h.revision();
+
+ h.undo();
+ assert_ne!(h.revision(), saved, "undo leaves a different state");
+
+ // A different second edit, landing at the same stack depth.
+ let (f3, i3) = edit("bcdef", 1, 2, None);
+ h.commit(f3, i3);
+ assert_eq!(h.len(), 2, "same depth as when we saved");
+ assert_ne!(
+ h.revision(),
+ saved,
+ "same depth, different content: must not look saved"
+ );
+ }
+
+ #[test]
+ fn mjb_llr_116_revision_is_zero_when_pristine_and_returns_on_undo() {
+ let mut h = History::new();
+ assert_eq!(h.revision(), 0);
+
+ let (f, i) = edit("abc", 0, 1, None);
+ h.commit(f, i);
+ let after = h.revision();
+ assert_ne!(after, 0);
+
+ h.undo();
+ assert_eq!(h.revision(), 0, "undoing to pristine returns to revision 0");
+ h.redo();
+ assert_eq!(h.revision(), after, "redo restores the same revision");
+ }
+
+ #[test]
+ fn mjb_llr_052_undo_walks_back_in_order() {
+ let mut h = History::new();
+ for n in 0..3 {
+ let (f, i) = edit("abcdef", n, n + 1, None);
+ h.commit(f, i);
+ }
+ assert_eq!(h.len(), 3);
+ for _ in 0..3 {
+ assert!(h.undo().is_some());
+ }
+ assert!(h.undo().is_none(), "exhausted");
+ }
+}
diff --git a/src/buffer/keymap.rs b/src/buffer/keymap.rs
new file mode 100644
index 0000000..61009c3
--- /dev/null
+++ b/src/buffer/keymap.rs
@@ -0,0 +1,392 @@
+//! Modal keymap resolution (MJB-HLR-015).
+//!
+//! Replaces the application template's resolver, which looked up single keys
+//! first and otherwise accumulated a buffer cleared on every `Action::Tick` —
+//! giving multi-key sequences a ~250 ms timeout at the default tick rate, so
+//! `gg` failed if typed slowly. Here the set of proper prefixes is precomputed,
+//! so resolution is exact and **time-independent** (MJB-LLR-154).
+//!
+//! A static table cannot express everything a modal editor needs: insert mode
+//! must treat any unbound printable key as self-insert, and counts are typed as
+//! ordinary digits. Both are handled around the table rather than in it, by
+//! [`KeymapResult::Cancelled`] and by count accumulation.
+
+use std::collections::{HashMap, HashSet};
+
+use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
+
+use super::command::Command;
+use crate::config::{KeyBindings, Mode};
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum KeymapResult {
+ /// The keys so far are a prefix of at least one binding; wait for more.
+ Pending,
+ /// A binding matched, carrying any count typed before it.
+ Matched(Command, Option<usize>),
+ /// The keys match nothing. Carries what was accumulated so the caller can
+ /// apply a mode-specific fallback, such as insert-mode self-insert.
+ Cancelled(Vec<KeyEvent>),
+}
+
+#[derive(Debug, Default)]
+pub struct Keymap {
+ bindings: HashMap<Mode, HashMap<Vec<KeyEvent>, Command>>,
+ /// MJB-LLR-150: every proper prefix of every bound sequence, per mode.
+ prefixes: HashMap<Mode, HashSet<Vec<KeyEvent>>>,
+ pending: Vec<KeyEvent>,
+ count: Option<usize>,
+}
+
+impl Keymap {
+ /// MJB-LLR-150
+ pub fn new(bindings: &KeyBindings) -> Self {
+ let mut prefixes: HashMap<Mode, HashSet<Vec<KeyEvent>>> = HashMap::new();
+
+ for (mode, map) in &bindings.0 {
+ let set = prefixes.entry(*mode).or_default();
+ for keys in map.keys() {
+ for n in 1..keys.len() {
+ set.insert(keys[..n].to_vec());
+ }
+ }
+ }
+
+ Self {
+ bindings: bindings.0.clone(),
+ prefixes,
+ pending: Vec::new(),
+ count: None,
+ }
+ }
+
+ pub fn pending(&self) -> &[KeyEvent] {
+ &self.pending
+ }
+
+ pub fn count(&self) -> Option<usize> {
+ self.count
+ }
+
+ /// Abandon any partial sequence and count, e.g. on a mode change.
+ pub fn reset(&mut self) {
+ self.pending.clear();
+ self.count = None;
+ }
+
+ /// Look up a single key without disturbing pending state. Used by `App` for
+ /// the `Global` map, which has no multi-key bindings (MJB-LLR-203).
+ pub fn lookup_single(&self, mode: Mode, key: KeyEvent) -> Option<Command> {
+ self.bindings.get(&mode)?.get(&vec![key]).copied()
+ }
+
+ /// MJB-LLR-151..156: feed one key and resolve.
+ pub fn resolve(&mut self, mode: Mode, key: KeyEvent) -> KeymapResult {
+ // MJB-LLR-155: digits typed before a command form a count. Only while
+ // no sequence is pending, so `g` then `1` is not swallowed.
+ if self.pending.is_empty()
+ && matches!(mode, Mode::Normal | Mode::Select)
+ && let KeyCode::Char(c) = key.code
+ && c.is_ascii_digit()
+ && !key.modifiers.contains(KeyModifiers::CONTROL)
+ && !key.modifiers.contains(KeyModifiers::ALT)
+ {
+ let digit = (c as u8 - b'0') as usize;
+ // A leading zero is not a count; it stays available as a binding.
+ if digit != 0 || self.count.is_some() {
+ self.count = Some(self.count.unwrap_or(0) * 10 + digit);
+ return KeymapResult::Pending;
+ }
+ }
+
+ self.pending.push(key);
+
+ // MJB-LLR-151
+ if let Some(&cmd) = self.bindings.get(&mode).and_then(|m| m.get(&self.pending)) {
+ let count = self.count.take();
+ self.pending.clear();
+ return KeymapResult::Matched(cmd, count);
+ }
+
+ // MJB-LLR-152
+ if self
+ .prefixes
+ .get(&mode)
+ .is_some_and(|set| set.contains(&self.pending))
+ {
+ return KeymapResult::Pending;
+ }
+
+ // MJB-LLR-153
+ let keys = std::mem::take(&mut self.pending);
+ self.count = None;
+ KeymapResult::Cancelled(keys)
+ }
+}
+
+/// MJB-LLR-156: the insert-mode fallback — a bare printable character.
+///
+/// Control and Alt are excluded so an unbound `Ctrl-x` is discarded rather than
+/// inserting `x`. Shift is allowed: it is how capitals are typed.
+pub fn self_insert_char(keys: &[KeyEvent]) -> Option<char> {
+ let [key] = keys else {
+ return None;
+ };
+ let KeyCode::Char(c) = key.code else {
+ return None;
+ };
+ if key.modifiers.contains(KeyModifiers::CONTROL) || key.modifiers.contains(KeyModifiers::ALT) {
+ return None;
+ }
+ Some(c)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::config::parse_key_sequence;
+
+ fn key(c: char) -> KeyEvent {
+ KeyEvent::new(KeyCode::Char(c), KeyModifiers::empty())
+ }
+
+ fn ctrl(c: char) -> KeyEvent {
+ KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
+ }
+
+ fn keymap() -> Keymap {
+ let mut bindings = KeyBindings::default();
+ let mut normal = HashMap::new();
+ normal.insert(parse_key_sequence("<h>").unwrap(), Command::MoveCharLeft);
+ normal.insert(parse_key_sequence("<g><g>").unwrap(), Command::GotoFileStart);
+ normal.insert(parse_key_sequence("<g><e>").unwrap(), Command::GotoLastLine);
+ bindings.0.insert(Mode::Normal, normal);
+
+ let mut insert = HashMap::new();
+ insert.insert(parse_key_sequence("<esc>").unwrap(), Command::NormalMode);
+ bindings.0.insert(Mode::Insert, insert);
+
+ Keymap::new(&bindings)
+ }
+
+ /// MJB-LLR-150: every proper prefix is precomputed, and only proper
+ /// prefixes — a complete binding is not itself registered as a prefix, or
+ /// it would never resolve.
+ #[test]
+ fn mjb_llr_150_proper_prefixes_are_precomputed() {
+ let k = keymap();
+ let set = k.prefixes.get(&Mode::Normal).expect("normal prefixes");
+
+ assert!(
+ set.contains(&parse_key_sequence("<g>").unwrap()),
+ "`g` is a proper prefix of `gg` and `ge`"
+ );
+ assert!(
+ !set.contains(&parse_key_sequence("<g><g>").unwrap()),
+ "a complete binding must not be registered as a prefix"
+ );
+ assert!(
+ !set.contains(&parse_key_sequence("<h>").unwrap()),
+ "a single-key binding has no proper prefix"
+ );
+ }
+
+ #[test]
+ fn mjb_llr_150_prefix_set_is_empty_for_a_mode_without_sequences() {
+ let k = keymap();
+ let set = k.prefixes.get(&Mode::Insert).expect("insert prefixes");
+ assert!(set.is_empty(), "insert has only single-key bindings");
+ }
+
+ #[test]
+ fn mjb_llr_151_single_key_binding_matches_immediately() {
+ let mut k = keymap();
+ assert_eq!(
+ k.resolve(Mode::Normal, key('h')),
+ KeymapResult::Matched(Command::MoveCharLeft, None)
+ );
+ assert!(k.pending().is_empty(), "pending must be cleared");
+ }
+
+ /// MJB-LLR-152, MJB-LLR-154: `g` is a prefix, so it waits — indefinitely,
+ /// with no timer involved. This is the bug the old resolver had.
+ #[test]
+ fn mjb_llr_152_prefix_key_waits_for_more() {
+ let mut k = keymap();
+ assert_eq!(k.resolve(Mode::Normal, key('g')), KeymapResult::Pending);
+ assert_eq!(k.pending().len(), 1);
+ }
+
+ #[test]
+ fn mjb_llr_151_two_key_sequence_resolves() {
+ let mut k = keymap();
+ assert_eq!(k.resolve(Mode::Normal, key('g')), KeymapResult::Pending);
+ assert_eq!(
+ k.resolve(Mode::Normal, key('g')),
+ KeymapResult::Matched(Command::GotoFileStart, None)
+ );
+ }
+
+ #[test]
+ fn mjb_llr_151_sequences_sharing_a_prefix_stay_distinct() {
+ let mut k = keymap();
+ k.resolve(Mode::Normal, key('g'));
+ assert_eq!(
+ k.resolve(Mode::Normal, key('e')),
+ KeymapResult::Matched(Command::GotoLastLine, None)
+ );
+ }
+
+ #[test]
+ fn mjb_llr_153_unknown_continuation_cancels() {
+ let mut k = keymap();
+ k.resolve(Mode::Normal, key('g'));
+ let got = k.resolve(Mode::Normal, key('z'));
+ match got {
+ KeymapResult::Cancelled(keys) => assert_eq!(keys, vec![key('g'), key('z')]),
+ other => panic!("expected Cancelled, got {other:?}"),
+ }
+ assert!(k.pending().is_empty());
+ }
+
+ #[test]
+ fn mjb_llr_153_unbound_key_cancels_immediately() {
+ let mut k = keymap();
+ match k.resolve(Mode::Normal, key('z')) {
+ KeymapResult::Cancelled(keys) => assert_eq!(keys, vec![key('z')]),
+ other => panic!("expected Cancelled, got {other:?}"),
+ }
+ }
+
+ /// MJB-LLR-154: no elapsed-time input exists, so a pending sequence
+ /// survives arbitrarily many unrelated resolutions in other modes.
+ #[test]
+ fn mjb_llr_154_pending_is_not_discarded_by_time() {
+ let mut k = keymap();
+ k.resolve(Mode::Normal, key('g'));
+ assert_eq!(k.pending().len(), 1);
+ // Nothing but another key can advance or clear it.
+ assert_eq!(
+ k.resolve(Mode::Normal, key('g')),
+ KeymapResult::Matched(Command::GotoFileStart, None)
+ );
+ }
+
+ #[test]
+ fn mjb_llr_155_count_accumulates_and_is_delivered() {
+ let mut k = keymap();
+ assert_eq!(k.resolve(Mode::Normal, key('1')), KeymapResult::Pending);
+ assert_eq!(k.resolve(Mode::Normal, key('2')), KeymapResult::Pending);
+ assert_eq!(
+ k.resolve(Mode::Normal, key('h')),
+ KeymapResult::Matched(Command::MoveCharLeft, Some(12))
+ );
+ }
+
+ #[test]
+ fn mjb_llr_155_count_is_consumed_once() {
+ let mut k = keymap();
+ k.resolve(Mode::Normal, key('3'));
+ k.resolve(Mode::Normal, key('h'));
+ assert_eq!(
+ k.resolve(Mode::Normal, key('h')),
+ KeymapResult::Matched(Command::MoveCharLeft, None),
+ "the count must not persist to the next command"
+ );
+ }
+
+ #[test]
+ fn mjb_llr_155_leading_zero_is_not_a_count() {
+ let mut k = keymap();
+ // `0` with no count in progress falls through to binding lookup.
+ match k.resolve(Mode::Normal, key('0')) {
+ KeymapResult::Cancelled(_) => {}
+ other => panic!("expected Cancelled for unbound 0, got {other:?}"),
+ }
+ assert_eq!(k.count(), None);
+ }
+
+ #[test]
+ fn mjb_llr_155_zero_extends_an_existing_count() {
+ let mut k = keymap();
+ k.resolve(Mode::Normal, key('1'));
+ k.resolve(Mode::Normal, key('0'));
+ assert_eq!(
+ k.resolve(Mode::Normal, key('h')),
+ KeymapResult::Matched(Command::MoveCharLeft, Some(10))
+ );
+ }
+
+ #[test]
+ fn mjb_llr_155_digits_are_not_counts_in_insert_mode() {
+ let mut k = keymap();
+ match k.resolve(Mode::Insert, key('5')) {
+ KeymapResult::Cancelled(keys) => {
+ assert_eq!(self_insert_char(&keys), Some('5'), "must type a 5");
+ }
+ other => panic!("expected Cancelled, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn mjb_llr_156_self_insert_accepts_plain_printables() {
+ assert_eq!(self_insert_char(&[key('a')]), Some('a'));
+ assert_eq!(
+ self_insert_char(&[KeyEvent::new(
+ KeyCode::Char('A'),
+ KeyModifiers::SHIFT
+ )]),
+ Some('A'),
+ "shift is how capitals are typed"
+ );
+ }
+
+ #[test]
+ fn mjb_llr_156_self_insert_rejects_control_and_alt() {
+ assert_eq!(self_insert_char(&[ctrl('x')]), None);
+ assert_eq!(
+ self_insert_char(&[KeyEvent::new(KeyCode::Char('x'), KeyModifiers::ALT)]),
+ None
+ );
+ }
+
+ #[test]
+ fn mjb_llr_156_self_insert_rejects_non_char_and_sequences() {
+ assert_eq!(
+ self_insert_char(&[KeyEvent::new(KeyCode::Esc, KeyModifiers::empty())]),
+ None
+ );
+ assert_eq!(
+ self_insert_char(&[key('a'), key('b')]),
+ None,
+ "only a single key can self-insert"
+ );
+ }
+
+ #[test]
+ fn mjb_llr_203_lookup_single_does_not_disturb_pending() {
+ let mut k = keymap();
+ k.resolve(Mode::Normal, key('g'));
+ assert_eq!(k.lookup_single(Mode::Normal, key('h')), Some(Command::MoveCharLeft));
+ assert_eq!(k.pending().len(), 1, "global lookup must be side-effect free");
+ }
+
+ #[test]
+ fn reset_clears_pending_and_count() {
+ let mut k = keymap();
+ k.resolve(Mode::Normal, key('3'));
+ k.resolve(Mode::Normal, key('g'));
+ k.reset();
+ assert!(k.pending().is_empty());
+ assert_eq!(k.count(), None);
+ }
+
+ #[test]
+ fn unknown_mode_cancels_rather_than_panicking() {
+ let mut k = keymap();
+ match k.resolve(Mode::Command, key('x')) {
+ KeymapResult::Cancelled(_) => {}
+ other => panic!("expected Cancelled for an unmapped mode, got {other:?}"),
+ }
+ }
+}
diff --git a/src/buffer/line_ending.rs b/src/buffer/line_ending.rs
new file mode 100644
index 0000000..42459de
--- /dev/null
+++ b/src/buffer/line_ending.rs
@@ -0,0 +1,144 @@
+//! Line ending detection and normalisation (MJB-HLR-003).
+//!
+//! The rope stores LF internally regardless of what the file used; the original
+//! terminator is recorded and reapplied on write, so opening and saving a CRLF
+//! file does not silently rewrite every line.
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub enum LineEnding {
+ #[default]
+ Lf,
+ Crlf,
+ Cr,
+}
+
+impl LineEnding {
+ pub fn as_str(self) -> &'static str {
+ match self {
+ LineEnding::Lf => "\n",
+ LineEnding::Crlf => "\r\n",
+ LineEnding::Cr => "\r",
+ }
+ }
+
+ /// The platform default, used when a buffer contains no terminator at all.
+ pub fn platform_default() -> Self {
+ if cfg!(windows) {
+ LineEnding::Crlf
+ } else {
+ LineEnding::Lf
+ }
+ }
+
+ /// MJB-LLR-114: the line ending of the first terminator present.
+ ///
+ /// Takes `&str` rather than a `RopeSlice`: detection runs on freshly
+ /// decoded text before the rope is built, and accepting a rope would force
+ /// the caller to construct one purely to answer this question.
+ ///
+ /// Scans bytes, not chars. CR and LF are ASCII and cannot appear as a
+ /// continuation byte of a multi-byte sequence, so a byte scan is both
+ /// correct and free of UTF-8 decoding.
+ pub fn detect(text: &str) -> Self {
+ match text.as_bytes().iter().position(|&b| b == b'\n' || b == b'\r') {
+ Some(i) if text.as_bytes()[i] == b'\n' => LineEnding::Lf,
+ // A CR followed by LF is CRLF; a CR followed by anything else, or
+ // by nothing at all, stood alone.
+ Some(i) if text.as_bytes().get(i + 1) == Some(&b'\n') => LineEnding::Crlf,
+ Some(_) => LineEnding::Cr,
+ // MJB-LLR-114: no terminator anywhere.
+ None => Self::platform_default(),
+ }
+ }
+}
+
+/// Rewrite every terminator in `text` (assumed LF-normalised) as `ending`.
+pub fn apply(text: &str, ending: LineEnding) -> String {
+ match ending {
+ LineEnding::Lf => text.to_owned(),
+ LineEnding::Crlf => text.replace('\n', "\r\n"),
+ LineEnding::Cr => text.replace('\n', "\r"),
+ }
+}
+
+/// Normalise CRLF and lone CR to LF for storage in the rope.
+pub fn normalize(text: &str) -> String {
+ if !text.contains('\r') {
+ return text.to_owned();
+ }
+ text.replace("\r\n", "\n").replace('\r', "\n")
+}
+
+/// Append a terminator when `text` is non-empty and lacks one (MJB-LLR-185).
+pub fn with_final_newline(text: &str) -> String {
+ if text.is_empty() || text.ends_with('\n') {
+ text.to_owned()
+ } else {
+ format!("{text}\n")
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn detect(s: &str) -> LineEnding {
+ LineEnding::detect(s)
+ }
+
+ #[test]
+ fn mjb_llr_114_detects_lf() {
+ assert_eq!(detect("a\nb\n"), LineEnding::Lf);
+ }
+
+ #[test]
+ fn mjb_llr_114_detects_crlf() {
+ assert_eq!(detect("a\r\nb\r\n"), LineEnding::Crlf);
+ }
+
+ #[test]
+ fn mjb_llr_114_detects_lone_cr() {
+ assert_eq!(detect("a\rb\r"), LineEnding::Cr);
+ }
+
+ #[test]
+ fn mjb_llr_114_falls_back_to_platform_default() {
+ assert_eq!(detect("no terminator"), LineEnding::platform_default());
+ assert_eq!(detect(""), LineEnding::platform_default());
+ }
+
+ #[test]
+ fn mjb_llr_114_first_terminator_decides() {
+ // Mixed endings: the first one wins, as documented.
+ assert_eq!(detect("a\nb\r\n"), LineEnding::Lf);
+ assert_eq!(detect("a\r\nb\n"), LineEnding::Crlf);
+ }
+
+ #[test]
+ fn normalize_collapses_to_lf() {
+ assert_eq!(normalize("a\r\nb\r\n"), "a\nb\n");
+ assert_eq!(normalize("a\rb\r"), "a\nb\n");
+ assert_eq!(normalize("a\nb\n"), "a\nb\n");
+ }
+
+ #[test]
+ fn mjb_llr_115_apply_restores_original_ending() {
+ assert_eq!(apply("a\nb\n", LineEnding::Crlf), "a\r\nb\r\n");
+ assert_eq!(apply("a\nb\n", LineEnding::Cr), "a\rb\r");
+ assert_eq!(apply("a\nb\n", LineEnding::Lf), "a\nb\n");
+ }
+
+ #[test]
+ fn crlf_round_trips_through_normalize_and_apply() {
+ let original = "one\r\ntwo\r\nthree\r\n";
+ let stored = normalize(original);
+ assert_eq!(apply(&stored, LineEnding::Crlf), original);
+ }
+
+ #[test]
+ fn mjb_llr_185_final_newline_only_added_when_missing() {
+ assert_eq!(with_final_newline("a"), "a\n");
+ assert_eq!(with_final_newline("a\n"), "a\n", "not doubled");
+ assert_eq!(with_final_newline(""), "", "empty buffer stays empty");
+ }
+}
diff --git a/src/buffer/mod.rs b/src/buffer/mod.rs
new file mode 100644
index 0000000..6924423
--- /dev/null
+++ b/src/buffer/mod.rs
@@ -0,0 +1,615 @@
+//! The buffer core: document model, motions, viewport, and input handling.
+//!
+//! Deliberately free of `ratatui` so it can be exercised by requirements-based
+//! tests without a terminal; the rendering half lives in
+//! [`crate::components::buffer`].
+//!
+//! Developed to DO-178C DAL-C. Items implementing a low-level requirement carry
+//! a `MJB-LLR-nnn` comment; see `docs/requirements/llr.md`.
+
+pub mod command;
+pub mod document;
+pub mod encoding;
+pub mod grapheme;
+pub mod history;
+pub mod keymap;
+pub mod line_ending;
+pub mod movement;
+pub mod save;
+pub mod selection;
+pub mod transaction;
+pub mod view;
+
+use std::path::PathBuf;
+
+use crossterm::event::KeyEvent;
+use ropey::{LineType, RopeSlice};
+
+use self::{
+ command::Command,
+ document::{Document, DocumentError},
+ keymap::{Keymap, KeymapResult, self_insert_char},
+ movement::{WordTarget, word_move},
+ selection::{Range, Selection},
+ transaction::Transaction,
+ view::View,
+};
+use crate::config::{Config, Mode};
+
+/// The line-break convention. `LF_CR` is what ropey enables by default, and
+/// recognises LF, CR and CRLF — matching the endings [`line_ending`] detects.
+pub const LINE_TYPE: LineType = LineType::LF_CR;
+
+/// What the caller should do after a key was handled.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum Outcome {
+ /// Handled internally; nothing for the application to do.
+ Consumed,
+ /// The user asked to quit.
+ Quit,
+ /// The user asked to suspend.
+ Suspend,
+}
+
+/// The editor state: one document, one viewport, one mode.
+pub struct Buffer {
+ pub document: Document,
+ pub view: View,
+ pub mode: Mode,
+ keymap: Keymap,
+ config: Config,
+ /// Command-line contents while in [`Mode::Command`].
+ pub command_line: String,
+ /// Transient message shown on the status line.
+ pub status: Option<String>,
+ /// Viewport height last rendered, needed by paging commands.
+ pub last_height: usize,
+ pub last_width: usize,
+}
+
+impl Buffer {
+ pub fn new(config: Config, path: Option<PathBuf>) -> Result<Self, DocumentError> {
+ // MJB-LLR-111, MJB-LLR-112
+ let document = match path {
+ Some(p) => Document::open(&p)?,
+ None => Document::empty(None),
+ };
+ let keymap = Keymap::new(&config.keybindings);
+
+ Ok(Self {
+ document,
+ view: View::new(),
+ mode: Mode::Normal,
+ keymap,
+ config,
+ command_line: String::new(),
+ status: None,
+ last_height: 0,
+ last_width: 0,
+ })
+ }
+
+ pub fn config(&self) -> &Config {
+ &self.config
+ }
+
+ /// Pending keys, for the status line.
+ pub fn pending_keys(&self) -> &[KeyEvent] {
+ self.keymap.pending()
+ }
+
+ /// Route one key according to the current mode.
+ pub fn handle_key(&mut self, key: KeyEvent) -> Outcome {
+ self.status = None;
+
+ // MJB-LLR-158: command mode is a line editor, not a keymap consumer.
+ // Its three bindings still resolve so Esc/Enter/Backspace stay
+ // configurable, but anything else types into the line.
+ if self.mode == Mode::Command {
+ return self.handle_command_mode_key(key);
+ }
+
+ match self.keymap.resolve(self.mode, key) {
+ KeymapResult::Pending => Outcome::Consumed,
+ KeymapResult::Matched(cmd, count) => self.execute(cmd, count.unwrap_or(1)),
+ KeymapResult::Cancelled(keys) => {
+ // MJB-LLR-156: insert mode types the character; every other
+ // mode discards it.
+ if self.mode == Mode::Insert
+ && let Some(c) = self_insert_char(&keys)
+ {
+ self.insert_char(c);
+ }
+ Outcome::Consumed
+ }
+ }
+ }
+
+ fn handle_command_mode_key(&mut self, key: KeyEvent) -> Outcome {
+ use crossterm::event::{KeyCode, KeyModifiers};
+
+ match self.keymap.resolve(self.mode, key) {
+ KeymapResult::Matched(Command::NormalMode, _) => {
+ self.command_line.clear();
+ self.set_mode(Mode::Normal);
+ Outcome::Consumed
+ }
+ KeymapResult::Matched(Command::CommandSubmit, _) => {
+ let line = std::mem::take(&mut self.command_line);
+ self.set_mode(Mode::Normal);
+ self.run_command_line(&line)
+ }
+ KeymapResult::Matched(Command::CommandBackspace, _) => {
+ if self.command_line.pop().is_none() {
+ // Backspacing an empty line leaves command mode, as Helix does.
+ self.set_mode(Mode::Normal);
+ }
+ Outcome::Consumed
+ }
+ _ => {
+ if let KeyCode::Char(c) = key.code
+ && !key.modifiers.contains(KeyModifiers::CONTROL)
+ && !key.modifiers.contains(KeyModifiers::ALT)
+ {
+ self.command_line.push(c);
+ }
+ Outcome::Consumed
+ }
+ }
+ }
+
+ /// MJB-LLR-159, MJB-LLR-160: parse and run a command line.
+ pub fn run_command_line(&mut self, line: &str) -> Outcome {
+ let line = line.trim();
+ let (name, _arg) = match line.split_once(char::is_whitespace) {
+ Some((n, a)) => (n, Some(a.trim())),
+ None => (line, None),
+ };
+
+ let insert_final_newline = self.config.editor.insert_final_newline;
+
+ match name {
+ "" => Outcome::Consumed,
+
+ // MJB-LLR-159
+ "w" | "write" => {
+ self.save(false, insert_final_newline);
+ Outcome::Consumed
+ }
+ "w!" | "write!" => {
+ self.save(true, insert_final_newline);
+ Outcome::Consumed
+ }
+
+ // MJB-LLR-160
+ "q" | "quit" => {
+ if self.document.is_modified() {
+ self.status =
+ Some("unsaved changes (use :q! to discard, :wq to save)".to_owned());
+ Outcome::Consumed
+ } else {
+ Outcome::Quit
+ }
+ }
+ "q!" | "quit!" => Outcome::Quit,
+
+ "wq" | "x" | "write-quit" => {
+ if self.save(false, insert_final_newline) {
+ Outcome::Quit
+ } else {
+ Outcome::Consumed
+ }
+ }
+
+ other => {
+ // MJB-LLR-159: report, do not terminate.
+ self.status = Some(format!("unknown command: {other}"));
+ Outcome::Consumed
+ }
+ }
+ }
+
+ /// Returns whether the write succeeded.
+ fn save(&mut self, force: bool, insert_final_newline: bool) -> bool {
+ match self.document.save(force, insert_final_newline) {
+ Ok(()) => {
+ let name = self
+ .document
+ .path()
+ .map(|p| p.display().to_string())
+ .unwrap_or_else(|| "[no name]".to_owned());
+ self.status = Some(format!("wrote {name}"));
+ true
+ }
+ Err(e) => {
+ self.status = Some(e.to_string());
+ false
+ }
+ }
+ }
+
+ fn set_mode(&mut self, mode: Mode) {
+ if self.mode != mode {
+ self.mode = mode;
+ // A half-typed sequence must not survive a mode change.
+ self.keymap.reset();
+ }
+ }
+
+ fn insert_char(&mut self, c: char) {
+ let mut s = [0u8; 4];
+ self.insert_text(c.encode_utf8(&mut s));
+ }
+
+ fn insert_text(&mut self, text: &str) {
+ let t = Transaction::insert(self.document.text(), self.document.selection(), text);
+ let at = self.document.range().cursor(self.document.slice());
+ if self.apply(&t) {
+ // Cursor advances past what was inserted.
+ self.document.set_range(Range::point(at + text.len()));
+ }
+ }
+
+ fn apply(&mut self, t: &Transaction) -> bool {
+ match self.document.apply(t) {
+ Ok(()) => true,
+ Err(e) => {
+ self.status = Some(e.to_string());
+ false
+ }
+ }
+ }
+
+ /// Execute one command. `count` is at least 1.
+ pub fn execute(&mut self, cmd: Command, count: usize) -> Outcome {
+ use Command::*;
+
+ let count = count.max(1);
+ let extend = self.mode == Mode::Select;
+
+ match cmd {
+ Quit => return Outcome::Quit,
+ Suspend => return Outcome::Suspend,
+
+ // --- Modes ---
+ NormalMode => self.set_mode(Mode::Normal),
+ InsertMode => {
+ // MJB-LLR-009: `i` inserts before the selection.
+ let from = self.document.range().from();
+ self.document.set_range(Range::point(from));
+ self.set_mode(Mode::Insert);
+ }
+ SelectMode => {
+ self.set_mode(if self.mode == Mode::Select {
+ Mode::Normal
+ } else {
+ Mode::Select
+ });
+ }
+ CommandMode => {
+ self.command_line.clear();
+ self.set_mode(Mode::Command);
+ }
+
+ // --- Motion (MJB-HLR-006) ---
+ MoveCharLeft => self.motion(extend, |t, r| movement::move_char_left(t, r, count)),
+ MoveCharRight => self.motion(extend, |t, r| movement::move_char_right(t, r, count)),
+ MoveLineUp => self.motion(extend, |t, r| movement::move_vertically(t, r, count, false)),
+ MoveLineDown => self.motion(extend, |t, r| movement::move_vertically(t, r, count, true)),
+ ExtendCharLeft => self.motion(true, |t, r| movement::move_char_left(t, r, count)),
+ ExtendCharRight => self.motion(true, |t, r| movement::move_char_right(t, r, count)),
+ ExtendLineUp => self.motion(true, |t, r| movement::move_vertically(t, r, count, false)),
+ ExtendLineDown => self.motion(true, |t, r| movement::move_vertically(t, r, count, true)),
+
+ // --- Word motion; these leave selections (MJB-HLR-007) ---
+ MoveNextWordStart => self.word(count, WordTarget::NextStart, false),
+ MovePrevWordStart => self.word(count, WordTarget::PrevStart, false),
+ MoveNextWordEnd => self.word(count, WordTarget::NextEnd, false),
+ MoveNextLongWordStart => self.word(count, WordTarget::NextStart, true),
+ MovePrevLongWordStart => self.word(count, WordTarget::PrevStart, true),
+ MoveNextLongWordEnd => self.word(count, WordTarget::NextEnd, true),
+ ExtendNextWordStart => self.word(count, WordTarget::NextStart, false),
+ ExtendPrevWordStart => self.word(count, WordTarget::PrevStart, false),
+ ExtendNextWordEnd => self.word(count, WordTarget::NextEnd, false),
+
+ // --- Goto (MJB-HLR-008) ---
+ GotoFileStart => {
+ let r = movement::goto_file_start(self.document.slice());
+ self.put(r, extend);
+ }
+ GotoLastLine => {
+ let r = movement::goto_last_line(self.document.slice());
+ self.put(r, extend);
+ }
+ GotoLineStart => {
+ let r = movement::goto_line_start(self.document.slice(), self.document.range());
+ self.put(r, extend);
+ }
+ GotoLineEnd => {
+ let r = movement::goto_line_end(self.document.slice(), self.document.range());
+ self.put(r, extend);
+ }
+ GotoFirstNonWhitespace => {
+ let r =
+ movement::goto_first_non_whitespace(self.document.slice(), self.document.range());
+ self.put(r, extend);
+ }
+
+ // --- Selection manipulation ---
+ ExtendLineBelow => self.extend_line_below(count),
+ CollapseSelection => {
+ let cursor = self.document.range().cursor(self.document.slice());
+ self.document.set_range(Range::point(cursor));
+ }
+ FlipSelections => {
+ let r = self.document.range().flipped();
+ self.document.set_range(r);
+ }
+ SelectAll => {
+ let len = self.document.text().len();
+ self.document.set_range(Range::new(0, len));
+ }
+
+ // --- Entering insert mode (MJB-HLR-009) ---
+ AppendMode => {
+ // `a` inserts after the selection. In Helix a bare cursor is a
+ // one-grapheme range, so appending lands *past* the grapheme
+ // under it; our empty range has to step forward explicitly to
+ // reproduce that.
+ let text = self.document.slice();
+ let r = self.document.range();
+ let at = if r.is_empty() {
+ grapheme::next_grapheme_boundary(text, r.cursor(text))
+ } else {
+ r.to()
+ };
+ self.document.set_range(Range::point(at));
+ self.set_mode(Mode::Insert);
+ }
+ InsertAtLineStart => {
+ let r =
+ movement::goto_first_non_whitespace(self.document.slice(), self.document.range());
+ self.document.set_range(Range::point(r.head));
+ self.set_mode(Mode::Insert);
+ }
+ InsertAtLineEnd => {
+ let r = movement::goto_line_end(self.document.slice(), self.document.range());
+ self.document.set_range(Range::point(r.head));
+ self.set_mode(Mode::Insert);
+ }
+ OpenBelow => self.open_line(false),
+ OpenAbove => self.open_line(true),
+
+ // --- Modification (MJB-HLR-010) ---
+ DeleteSelection => self.delete_selection(),
+ ChangeSelection => {
+ self.delete_selection();
+ self.set_mode(Mode::Insert);
+ }
+ InsertNewline => self.insert_text("\n"),
+ InsertTab => self.insert_text("\t"),
+ DeleteCharBackward => self.delete_char_backward(),
+ DeleteCharForward => self.delete_char_forward(),
+ DeleteWordBackward => self.delete_word_backward(),
+ KillToLineStart => self.kill_to_line_start(),
+
+ // --- Undo / redo (MJB-HLR-011) ---
+ Undo => match self.document.undo() {
+ Ok(false) => self.status = Some("already at oldest change".to_owned()),
+ Ok(true) => {}
+ Err(e) => self.status = Some(e.to_string()),
+ },
+ Redo => match self.document.redo() {
+ Ok(false) => self.status = Some("already at newest change".to_owned()),
+ Ok(true) => {}
+ Err(e) => self.status = Some(e.to_string()),
+ },
+
+ // --- Paging (MJB-HLR-013) ---
+ PageCursorHalfUp => self.page(self.last_height / 2, false),
+ PageCursorHalfDown => self.page(self.last_height / 2, true),
+ PageUp => self.page(self.last_height, false),
+ PageDown => self.page(self.last_height, true),
+
+ // Handled by handle_command_mode_key; unreachable elsewhere but
+ // must not panic if a user binds them outside command mode.
+ CommandSubmit | CommandBackspace => {}
+ }
+
+ Outcome::Consumed
+ }
+
+ // --- helpers ---
+
+ /// Run a motion and install its result, extending the selection or
+ /// collapsing to a point per `extend`.
+ ///
+ /// Takes a closure rather than a function pointer so the direction and
+ /// count stay visible at the call site, instead of hiding behind a family
+ /// of near-identical adapter functions.
+ fn motion(&mut self, extend: bool, f: impl FnOnce(RopeSlice, Range) -> Range) {
+ let text = self.document.slice();
+ let r = f(text, self.document.range());
+ self.put(r, extend);
+ }
+
+ fn put(&mut self, target: Range, extend: bool) {
+ let text = self.document.slice();
+ let current = self.document.range();
+ let r = current.put_cursor(text, target.cursor(text), extend);
+ self.document.set_range(r);
+ }
+
+ /// MJB-LLR-065..067: word motions install the returned range directly,
+ /// because the range *is* the result — collapsing it would destroy the
+ /// selection-first behaviour that makes `wd` work.
+ fn word(&mut self, count: usize, target: WordTarget, long: bool) {
+ let text = self.document.slice();
+ let r = word_move(text, self.document.range(), count, target, long);
+ self.document.set_range(r);
+ }
+
+ /// Helix's `x`: select the current line; repeated, extend by one more.
+ fn extend_line_below(&mut self, count: usize) {
+ let text = self.document.slice();
+ let r = self.document.range();
+ let (start_line, end_line) = r.line_range(text);
+
+ let already_whole_line = r.from() == text.line_to_byte_idx(start_line, LINE_TYPE)
+ && r.to() == line_start_of_next(text, end_line);
+
+ let (first, last) = if already_whole_line {
+ (start_line, (end_line + count).min(last_line_index(text)))
+ } else {
+ (start_line, (end_line + count - 1).min(last_line_index(text)))
+ };
+
+ let from = text.line_to_byte_idx(first, LINE_TYPE);
+ let to = line_start_of_next(text, last);
+ self.document.set_range(Range::new(from, to));
+ }
+
+ fn open_line(&mut self, above: bool) {
+ let text = self.document.slice();
+ let line = self.document.range().cursor_line(text);
+
+ // `above` inserts the terminator at the line's start, so the blank line
+ // appears *at* that offset. `below` inserts it after the line's content
+ // — deliberately at the content end rather than at the next line's
+ // start, because a final line with no trailing newline has no next line
+ // to anchor to, and the blank line then lands one byte later.
+ let (at, cursor) = if above {
+ let start = text.line_to_byte_idx(line, LINE_TYPE);
+ (start, start)
+ } else {
+ let eol = movement::line_end_byte(text, line);
+ (eol, eol + 1)
+ };
+
+ let t = Transaction::change(self.document.text(), [(at, at, Some("\n".to_owned()))]);
+ if self.apply(&t) {
+ self.document.set_range(Range::point(cursor));
+ self.set_mode(Mode::Insert);
+ }
+ }
+
+ fn delete_selection(&mut self) {
+ let r = self.document.range();
+ if r.is_empty() {
+ // MJB-LLR-050 robustness: `d` with nothing selected deletes the
+ // grapheme under the cursor rather than doing nothing.
+ let text = self.document.slice();
+ let to = grapheme::next_grapheme_boundary(text, r.cursor(text));
+ if to == r.from() {
+ return;
+ }
+ let t = Transaction::change(self.document.text(), [(r.from(), to, None)]);
+ let from = r.from();
+ if self.apply(&t) {
+ self.document.set_range(Range::point(from));
+ }
+ return;
+ }
+
+ let from = r.from();
+ let t = Transaction::delete(self.document.text(), self.document.selection());
+ if self.apply(&t) {
+ self.document.set_range(Range::point(from));
+ }
+ }
+
+ fn delete_char_backward(&mut self) {
+ let text = self.document.slice();
+ let cursor = self.document.range().cursor(text);
+ let from = grapheme::prev_grapheme_boundary(text, cursor);
+ if from == cursor {
+ return; // at the start of the buffer
+ }
+ let t = Transaction::change(self.document.text(), [(from, cursor, None)]);
+ if self.apply(&t) {
+ self.document.set_range(Range::point(from));
+ }
+ }
+
+ fn delete_char_forward(&mut self) {
+ let text = self.document.slice();
+ let cursor = self.document.range().cursor(text);
+ let to = grapheme::next_grapheme_boundary(text, cursor);
+ if to == cursor {
+ return; // at the end of the buffer
+ }
+ let t = Transaction::change(self.document.text(), [(cursor, to, None)]);
+ if self.apply(&t) {
+ self.document.set_range(Range::point(cursor));
+ }
+ }
+
+ fn delete_word_backward(&mut self) {
+ let text = self.document.slice();
+ let cursor = self.document.range().cursor(text);
+ if cursor == 0 {
+ return;
+ }
+ let target = word_move(text, Range::point(cursor), 1, WordTarget::PrevStart, false);
+ let from = target.from();
+ if from >= cursor {
+ return;
+ }
+ let t = Transaction::change(self.document.text(), [(from, cursor, None)]);
+ if self.apply(&t) {
+ self.document.set_range(Range::point(from));
+ }
+ }
+
+ fn kill_to_line_start(&mut self) {
+ let text = self.document.slice();
+ let cursor = self.document.range().cursor(text);
+ let line = text.byte_to_line_idx(cursor, LINE_TYPE);
+ let from = text.line_to_byte_idx(line, LINE_TYPE);
+ if from >= cursor {
+ return;
+ }
+ let t = Transaction::change(self.document.text(), [(from, cursor, None)]);
+ if self.apply(&t) {
+ self.document.set_range(Range::point(from));
+ }
+ }
+
+ fn page(&mut self, lines: usize, down: bool) {
+ if lines == 0 {
+ return;
+ }
+ let text = self.document.slice();
+ let r = self.view.page(text, self.document.range(), lines, down);
+ self.document.set_range(r);
+ }
+
+ /// Re-anchor the viewport for a viewport of `height` rows.
+ pub fn update_view(&mut self, width: usize, height: usize) {
+ self.last_width = width;
+ self.last_height = height;
+ let text = self.document.slice();
+ let range = self.document.range();
+ self.view
+ .ensure_cursor_in_view(text, range, height, self.config.editor.scrolloff);
+ self.view.ensure_horizontal_in_view(text, range, width);
+ }
+
+ /// Replace the whole selection state — used by tests and by `%`.
+ pub fn set_selection(&mut self, selection: Selection) {
+ self.document.set_selection(selection);
+ }
+}
+
+fn line_start_of_next(text: RopeSlice, line: usize) -> usize {
+ let total = text.len_lines(LINE_TYPE);
+ if line + 1 < total {
+ text.line_to_byte_idx(line + 1, LINE_TYPE)
+ } else {
+ text.len()
+ }
+}
+
+fn last_line_index(text: RopeSlice) -> usize {
+ text.len_lines(LINE_TYPE).saturating_sub(1)
+}
diff --git a/src/buffer/movement.rs b/src/buffer/movement.rs
new file mode 100644
index 0000000..8dc0ff7
--- /dev/null
+++ b/src/buffer/movement.rs
@@ -0,0 +1,592 @@
+//! Motions — after Helix's `helix-core/src/movement.rs`.
+//!
+//! The defining property, and the one most easily got wrong: **word motions
+//! return a selection, not a point**. Helix's keymap documents `w` as "move
+//! next word start", but `range_to_target` returns a `Range` whose anchor is
+//! the pre-motion position and whose head is the target. That is why `d` after
+//! `w` deletes a word with no operator-pending machinery anywhere
+//! (MJB-HLR-007, MJB-LLR-065..067).
+//!
+//! Implemented against `RopeSlice::char_indices_at`, which yields
+//! `(byte_idx, char)` and supports `prev()`, so both directions are byte-native
+//! rather than translated from char offsets.
+
+use ropey::RopeSlice;
+
+use super::{
+ LINE_TYPE,
+ grapheme::{byte_at_display_column, display_column, next_grapheme_boundary, prev_grapheme_boundary},
+ selection::Range,
+};
+
+/// MJB-LLR-060: character classes that word motions stop between.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum CharCategory {
+ Eol,
+ Whitespace,
+ Word,
+ Punctuation,
+}
+
+/// MJB-LLR-060
+pub fn categorize_char(c: char) -> CharCategory {
+ if c == '\n' || c == '\r' {
+ CharCategory::Eol
+ } else if c.is_whitespace() {
+ CharCategory::Whitespace
+ } else if c.is_alphanumeric() || c == '_' {
+ CharCategory::Word
+ } else {
+ CharCategory::Punctuation
+ }
+}
+
+/// A coarser classification backing the long-word motions `W`/`B`/`E`, which
+/// treat punctuation as part of the word.
+fn categorize_long(c: char) -> CharCategory {
+ match categorize_char(c) {
+ CharCategory::Punctuation => CharCategory::Word,
+ other => other,
+ }
+}
+
+/// MJB-LLR-061
+pub fn is_word_boundary(a: char, b: char) -> bool {
+ categorize_char(a) != categorize_char(b)
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum WordTarget {
+ NextStart,
+ NextEnd,
+ PrevStart,
+}
+
+fn categorizer(long: bool) -> fn(char) -> CharCategory {
+ if long { categorize_long } else { categorize_char }
+}
+
+/// MJB-LLR-062: one grapheme left, collapsing to a point.
+pub fn move_char_left(text: RopeSlice, range: Range, count: usize) -> Range {
+ let mut pos = range.cursor(text);
+ for _ in 0..count.max(1) {
+ let next = prev_grapheme_boundary(text, pos);
+ if next == pos {
+ break; // MJB-LLR-062: no-op at offset zero
+ }
+ pos = next;
+ }
+ Range::point(pos).clamped(text)
+}
+
+/// MJB-LLR-063: one grapheme right, collapsing to a point.
+pub fn move_char_right(text: RopeSlice, range: Range, count: usize) -> Range {
+ let mut pos = range.cursor(text);
+ for _ in 0..count.max(1) {
+ let next = next_grapheme_boundary(text, pos);
+ if next == pos {
+ break; // MJB-LLR-063: no-op at end of buffer
+ }
+ pos = next;
+ }
+ Range::point(pos).clamped(text)
+}
+
+/// MJB-LLR-064: vertical motion preserving the display column.
+pub fn move_vertically(text: RopeSlice, range: Range, count: usize, down: bool) -> Range {
+ let cursor = 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 column = display_column(text.line(line, LINE_TYPE), cursor - line_start);
+
+ let last_line = text.len_lines(LINE_TYPE).saturating_sub(1);
+ let target_line = if down {
+ line.saturating_add(count.max(1)).min(last_line)
+ } else {
+ line.saturating_sub(count.max(1))
+ };
+
+ // MJB-LLR-064: a no-op on the first or last line.
+ if target_line == line {
+ return range;
+ }
+
+ let target_start = text.line_to_byte_idx(target_line, LINE_TYPE);
+ let offset = byte_at_display_column(text.line(target_line, LINE_TYPE), column);
+ Range::point(target_start + offset).clamped(text)
+}
+
+/// MJB-LLR-065..069: word motion.
+///
+/// Returns a range spanning from the pre-motion cursor to the target, so the
+/// traversed text ends up selected.
+pub fn word_move(
+ text: RopeSlice,
+ range: Range,
+ count: usize,
+ target: WordTarget,
+ long: bool,
+) -> Range {
+ let cat = categorizer(long);
+
+ // The anchor is the position the whole traversal started from and does not
+ // move; only the head advances, once per count. Re-deriving the start from
+ // the partial result each iteration would restart from the *cursor* — one
+ // grapheme behind the head — so `2w` would stall inside the first gap
+ // instead of reaching the second word.
+ let anchor = range.cursor(text);
+ let mut head = anchor;
+
+ for _ in 0..count.max(1) {
+ let next = match target {
+ WordTarget::NextStart => next_word_start(text, head, cat),
+ WordTarget::NextEnd => next_word_end(text, head, cat),
+ WordTarget::PrevStart => prev_word_start(text, head, cat),
+ };
+ if next == head {
+ break; // MJB-LLR-069: at the buffer boundary
+ }
+ head = next;
+ }
+
+ if head == anchor {
+ range
+ } else {
+ Range::new(anchor, head).clamped(text)
+ }
+}
+
+/// Characters that separate words rather than belonging to one.
+fn is_separator(category: CharCategory) -> bool {
+ matches!(category, CharCategory::Whitespace | CharCategory::Eol)
+}
+
+/// The char starting at byte `i`, with its start index.
+fn char_at(text: RopeSlice, i: usize) -> Option<(usize, char)> {
+ (i < text.len()).then(|| text.char_indices_at(i).next())?
+}
+
+/// The char ending at byte `i` — the one immediately before it.
+fn char_before(text: RopeSlice, i: usize) -> Option<(usize, char)> {
+ (i > 0).then(|| text.char_indices_at(i).prev())?
+}
+
+/// MJB-LLR-065: first character of the word after `from`.
+///
+/// Runs out whatever category the cursor sits on, then skips separators.
+fn next_word_start(text: RopeSlice, from: usize, cat: fn(char) -> CharCategory) -> usize {
+ let mut i = from;
+
+ if let Some((_, c)) = char_at(text, i) {
+ let run = cat(c);
+ while let Some((s, ch)) = char_at(text, i) {
+ if cat(ch) != run {
+ break;
+ }
+ i = s + ch.len_utf8();
+ }
+ }
+
+ // MJB-LLR-068
+ while let Some((s, c)) = char_at(text, i) {
+ if !is_separator(cat(c)) {
+ break;
+ }
+ i = s + c.len_utf8();
+ }
+
+ i
+}
+
+/// MJB-LLR-067: one past the last character of the word after `from`.
+///
+/// Steps off the current character first so `e` always advances, even when it
+/// already sits on the final character of a word.
+fn next_word_end(text: RopeSlice, from: usize, cat: fn(char) -> CharCategory) -> usize {
+ let mut i = from;
+
+ if let Some((s, c)) = char_at(text, i) {
+ i = s + c.len_utf8();
+ }
+
+ // MJB-LLR-068
+ while let Some((s, c)) = char_at(text, i) {
+ if !is_separator(cat(c)) {
+ break;
+ }
+ i = s + c.len_utf8();
+ }
+
+ if let Some((_, c)) = char_at(text, i) {
+ let run = cat(c);
+ while let Some((s, ch)) = char_at(text, i) {
+ if cat(ch) != run {
+ break;
+ }
+ i = s + ch.len_utf8();
+ }
+ }
+
+ i
+}
+
+/// MJB-LLR-066: first character of the word before `from`.
+fn prev_word_start(text: RopeSlice, from: usize, cat: fn(char) -> CharCategory) -> usize {
+ let mut i = from;
+
+ // MJB-LLR-068: skip separators immediately behind the cursor.
+ while let Some((s, c)) = char_before(text, i) {
+ if !is_separator(cat(c)) {
+ break;
+ }
+ i = s;
+ }
+
+ let Some((_, c)) = char_before(text, i) else {
+ return i; // MJB-LLR-069: nothing but separators behind us
+ };
+ let run = cat(c);
+
+ while let Some((s, ch)) = char_before(text, i) {
+ if cat(ch) != run {
+ break;
+ }
+ i = s;
+ }
+
+ i
+}
+
+// --- Goto commands (MJB-HLR-008) ---
+
+/// MJB-LLR-070
+pub fn goto_file_start(text: RopeSlice) -> Range {
+ let _ = text;
+ Range::point(0)
+}
+
+/// MJB-LLR-071
+pub fn goto_last_line(text: RopeSlice) -> Range {
+ let last = last_content_line(text);
+ Range::point(text.line_to_byte_idx(last, LINE_TYPE)).clamped(text)
+}
+
+/// MJB-LLR-072
+pub fn goto_line_start(text: RopeSlice, range: Range) -> Range {
+ let line = range.cursor_line(text);
+ Range::point(text.line_to_byte_idx(line, LINE_TYPE)).clamped(text)
+}
+
+/// MJB-LLR-073: the last character of the line, excluding its terminator.
+pub fn goto_line_end(text: RopeSlice, range: Range) -> Range {
+ let line = range.cursor_line(text);
+ Range::point(line_end_byte(text, line)).clamped(text)
+}
+
+/// First non-whitespace character of the cursor's line.
+pub fn goto_first_non_whitespace(text: RopeSlice, range: Range) -> Range {
+ let line = range.cursor_line(text);
+ let start = text.line_to_byte_idx(line, LINE_TYPE);
+ let slice = text.line(line, LINE_TYPE);
+
+ let mut offset = 0;
+ for (i, c) in slice.char_indices() {
+ if !c.is_whitespace() || matches!(categorize_char(c), CharCategory::Eol) {
+ offset = i;
+ break;
+ }
+ offset = i + c.len_utf8();
+ }
+ Range::point(start + offset).clamped(text)
+}
+
+/// Byte offset just past the last non-terminator character of `line`.
+///
+/// Inspects the final bytes rather than materialising the line: LF and CR are
+/// single-byte ASCII and cannot occur as a continuation byte of a multi-byte
+/// character, so testing the trailing bytes is unambiguous.
+pub fn line_end_byte(text: RopeSlice, line: usize) -> usize {
+ let start = text.line_to_byte_idx(line, LINE_TYPE);
+ let slice = text.line(line, LINE_TYPE);
+ let mut end = slice.len();
+
+ if end > 0 && slice.byte(end - 1) == b'\n' {
+ end -= 1;
+ if end > 0 && slice.byte(end - 1) == b'\r' {
+ end -= 1; // CRLF
+ }
+ } else if end > 0 && slice.byte(end - 1) == b'\r' {
+ end -= 1; // lone CR
+ }
+
+ start + end
+}
+
+/// The last line holding content.
+///
+/// A buffer ending in a newline reports a trailing empty line; the cursor
+/// should land on the last line with text on it.
+pub fn last_content_line(text: RopeSlice) -> usize {
+ let lines = text.len_lines(LINE_TYPE);
+ if lines == 0 {
+ return 0;
+ }
+ let last = lines - 1;
+ if last > 0 && text.line(last, LINE_TYPE).len() == 0 {
+ last - 1
+ } else {
+ last
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use ropey::Rope;
+
+ use super::*;
+
+ fn r(s: &str) -> Rope {
+ Rope::from_str(s)
+ }
+
+ #[test]
+ fn mjb_llr_060_categories() {
+ assert_eq!(categorize_char('a'), CharCategory::Word);
+ assert_eq!(categorize_char('_'), CharCategory::Word);
+ assert_eq!(categorize_char('7'), CharCategory::Word);
+ assert_eq!(categorize_char(' '), CharCategory::Whitespace);
+ assert_eq!(categorize_char('\n'), CharCategory::Eol);
+ assert_eq!(categorize_char('.'), CharCategory::Punctuation);
+ }
+
+ #[test]
+ fn mjb_llr_061_word_boundary_is_category_change() {
+ assert!(is_word_boundary('a', ' '));
+ assert!(is_word_boundary('a', '.'));
+ assert!(!is_word_boundary('a', 'b'));
+ }
+
+ #[test]
+ fn mjb_llr_062_move_char_left_stops_at_zero() {
+ let t = r("abc");
+ let s = t.slice(..);
+ assert_eq!(move_char_left(s, Range::point(0), 1), Range::point(0));
+ assert_eq!(move_char_left(s, Range::point(2), 1), Range::point(1));
+ }
+
+ #[test]
+ fn mjb_llr_063_move_char_right_stops_at_end() {
+ let t = r("abc");
+ let s = t.slice(..);
+ assert_eq!(move_char_right(s, Range::point(3), 1), Range::point(3));
+ assert_eq!(move_char_right(s, Range::point(0), 1), Range::point(1));
+ }
+
+ #[test]
+ fn mjb_llr_063_move_char_right_skips_whole_multibyte_char() {
+ let t = r("文a");
+ let s = t.slice(..);
+ assert_eq!(move_char_right(s, Range::point(0), 1), Range::point(3));
+ }
+
+ #[test]
+ fn mjb_llr_064_vertical_motion_preserves_column() {
+ let t = r("abcdef\nghijkl\n");
+ let s = t.slice(..);
+ let down = move_vertically(s, Range::point(3), 1, true);
+ assert_eq!(down.cursor(s), 7 + 3, "same column on the next line");
+ let up = move_vertically(s, down, 1, false);
+ assert_eq!(up.cursor(s), 3);
+ }
+
+ #[test]
+ fn mjb_llr_064_vertical_motion_clamps_to_short_line() {
+ let t = r("abcdef\nxy\n");
+ let s = t.slice(..);
+ let down = move_vertically(s, Range::point(5), 1, true);
+ // Line "xy" has no column 5; clamp to its end.
+ assert_eq!(down.cursor(s), 7 + 2);
+ }
+
+ #[test]
+ fn mjb_llr_064_vertical_motion_is_noop_at_edges() {
+ let t = r("abc\ndef\n");
+ let s = t.slice(..);
+ let up = move_vertically(s, Range::point(1), 1, false);
+ assert_eq!(up, Range::point(1), "no-op on the first line");
+ }
+
+ /// The behaviour that distinguishes Helix from Vim: `w` leaves a selection.
+ #[test]
+ fn mjb_llr_065_next_word_start_produces_a_selection() {
+ let t = r("hello world");
+ let s = t.slice(..);
+ let got = word_move(s, Range::point(0), 1, WordTarget::NextStart, false);
+ assert!(!got.is_empty(), "w must leave a selection, not a point");
+ assert_eq!(got.anchor, 0, "anchor stays at the pre-motion cursor");
+ assert_eq!(got.head, 6, "head lands on the next word's first char");
+ }
+
+ #[test]
+ fn mjb_llr_067_next_word_end_spans_the_word() {
+ let t = r("hello world");
+ let s = t.slice(..);
+ let got = word_move(s, Range::point(0), 1, WordTarget::NextEnd, false);
+ assert_eq!(got.anchor, 0);
+ assert_eq!(got.head, 5, "inclusive of the word's last character");
+ }
+
+ #[test]
+ fn mjb_llr_066_prev_word_start_spans_backward() {
+ let t = r("hello world");
+ let s = t.slice(..);
+ let got = word_move(s, Range::point(6), 1, WordTarget::PrevStart, false);
+ assert_eq!(got.anchor, 6, "anchor stays at the pre-motion cursor");
+ assert_eq!(got.head, 0);
+ }
+
+ #[test]
+ fn mjb_llr_068_word_motion_stops_at_punctuation() {
+ let t = r("foo.bar");
+ let s = t.slice(..);
+ let got = word_move(s, Range::point(0), 1, WordTarget::NextStart, false);
+ assert_eq!(got.head, 3, "punctuation is its own category");
+ }
+
+ #[test]
+ fn mjb_llr_068_long_word_motion_absorbs_punctuation() {
+ let t = r("foo.bar baz");
+ let s = t.slice(..);
+ let got = word_move(s, Range::point(0), 1, WordTarget::NextStart, true);
+ assert_eq!(got.head, 8, "W treats foo.bar as one word");
+ }
+
+ /// MJB-LLR-065 with a count. Regression guard: when each iteration
+ /// re-derived its start from the partial range's *cursor* — one grapheme
+ /// behind the head — `2w` stalled inside the first gap instead of reaching
+ /// the second word.
+ #[test]
+ fn mjb_llr_065_counted_next_word_start_advances_once_per_count() {
+ let t = r("aaa bbb ccc ddd");
+ let s = t.slice(..);
+
+ let one = word_move(s, Range::point(0), 1, WordTarget::NextStart, false);
+ assert_eq!((one.anchor, one.head), (0, 4));
+
+ let two = word_move(s, Range::point(0), 2, WordTarget::NextStart, false);
+ assert_eq!(
+ (two.anchor, two.head),
+ (0, 8),
+ "2w must reach the third word's start, not stall in a gap"
+ );
+
+ let three = word_move(s, Range::point(0), 3, WordTarget::NextStart, false);
+ assert_eq!((three.anchor, three.head), (0, 12));
+ }
+
+ #[test]
+ fn mjb_llr_066_counted_prev_word_start_advances_once_per_count() {
+ let t = r("aaa bbb ccc");
+ let s = t.slice(..);
+ let two = word_move(s, Range::point(10), 2, WordTarget::PrevStart, false);
+ assert_eq!(two.anchor, 10, "anchor stays at the origin");
+ assert_eq!(two.head, 4, "two words back");
+ }
+
+ #[test]
+ fn mjb_llr_067_counted_next_word_end_advances_once_per_count() {
+ let t = r("aaa bbb ccc");
+ let s = t.slice(..);
+ let two = word_move(s, Range::point(0), 2, WordTarget::NextEnd, false);
+ assert_eq!((two.anchor, two.head), (0, 7), "end of the second word");
+ }
+
+ /// A count larger than the remaining words must saturate, not overshoot.
+ #[test]
+ fn mjb_llr_069_counted_motion_saturates_at_the_buffer_end() {
+ let t = r("aaa bbb");
+ let s = t.slice(..);
+ let got = word_move(s, Range::point(0), 99, WordTarget::NextStart, false);
+ assert!(got.head <= s.len());
+ assert_eq!(got.anchor, 0);
+ }
+
+ #[test]
+ fn mjb_llr_069_word_motion_is_noop_at_boundaries() {
+ let t = r("abc");
+ let s = t.slice(..);
+ let end = Range::point(3);
+ assert_eq!(word_move(s, end, 1, WordTarget::NextStart, false), end);
+ let start = Range::point(0);
+ assert_eq!(word_move(s, start, 1, WordTarget::PrevStart, false), start);
+ }
+
+ #[test]
+ fn mjb_llr_068_word_motion_crosses_line_endings() {
+ let t = r("foo\nbar");
+ let s = t.slice(..);
+ let got = word_move(s, Range::point(0), 1, WordTarget::NextStart, false);
+ assert_eq!(got.head, 4, "newline is skipped as a separator");
+ }
+
+ #[test]
+ fn mjb_llr_070_goto_file_start() {
+ let t = r("abc\ndef");
+ assert_eq!(goto_file_start(t.slice(..)), Range::point(0));
+ }
+
+ #[test]
+ fn mjb_llr_071_goto_last_line() {
+ let t = r("abc\ndef\n");
+ let s = t.slice(..);
+ // Trailing newline must not park the cursor on the phantom line.
+ assert_eq!(goto_last_line(s).cursor(s), 4);
+ }
+
+ #[test]
+ fn mjb_llr_071_goto_last_line_without_trailing_newline() {
+ let t = r("abc\ndef");
+ let s = t.slice(..);
+ assert_eq!(goto_last_line(s).cursor(s), 4);
+ }
+
+ #[test]
+ fn mjb_llr_072_goto_line_start() {
+ let t = r("abc\ndef\n");
+ let s = t.slice(..);
+ assert_eq!(goto_line_start(s, Range::point(6)).cursor(s), 4);
+ }
+
+ #[test]
+ fn mjb_llr_073_goto_line_end_excludes_terminator() {
+ let t = r("abc\ndef\n");
+ let s = t.slice(..);
+ assert_eq!(goto_line_end(s, Range::point(0)).cursor(s), 3);
+ }
+
+ #[test]
+ fn mjb_llr_073_goto_line_end_handles_crlf() {
+ let t = r("abc\r\ndef");
+ let s = t.slice(..);
+ assert_eq!(goto_line_end(s, Range::point(0)).cursor(s), 3);
+ }
+
+ #[test]
+ fn goto_first_non_whitespace_skips_indent() {
+ let t = r(" indented\n");
+ let s = t.slice(..);
+ assert_eq!(goto_first_non_whitespace(s, Range::point(0)).cursor(s), 4);
+ }
+
+ #[test]
+ fn empty_buffer_motions_are_safe() {
+ let t = r("");
+ let s = t.slice(..);
+ assert_eq!(move_char_left(s, Range::point(0), 1), Range::point(0));
+ assert_eq!(move_char_right(s, Range::point(0), 1), Range::point(0));
+ assert_eq!(goto_line_end(s, Range::point(0)).cursor(s), 0);
+ assert_eq!(goto_last_line(s).cursor(s), 0);
+ let w = word_move(s, Range::point(0), 1, WordTarget::NextStart, false);
+ assert_eq!(w, Range::point(0));
+ }
+}
diff --git a/src/buffer/save.rs b/src/buffer/save.rs
new file mode 100644
index 0000000..70dacd0
--- /dev/null
+++ b/src/buffer/save.rs
@@ -0,0 +1,387 @@
+//! File writing — ported from Helix's `helix-view/src/document.rs::save_impl`.
+//!
+//! Two distinct fallbacks, both required by MJB-HLR-017 and easy to conflate:
+//!
+//! 1. **copy instead of rename** when the target is a symlink or hardlink.
+//! Renaming the backup into place would break the link; copying preserves it.
+//! 2. **restore on failure** — the backup lives in the target's *own directory*
+//! so the rename can never cross a filesystem, and is put back if the write
+//! fails partway.
+//!
+//! The ordering matters: every check that can reject the write runs before
+//! anything on disk is touched.
+
+use std::{
+ fs,
+ io::{self, Write},
+ path::{Path, PathBuf},
+};
+
+#[derive(Debug, thiserror::Error)]
+pub enum SaveError {
+ #[error("no file name associated with this buffer")]
+ NoPath,
+ #[error("path is read only: {0}")]
+ ReadOnly(PathBuf),
+ #[error("can't save file, parent directory does not exist (use :w! to create it): {0}")]
+ NoParent(PathBuf),
+ #[error("io error: {0}")]
+ Io(#[from] io::Error),
+}
+
+/// MJB-LLR-131: writable without modifying anything.
+///
+/// A path that does not exist is *not* read-only — it may still be creatable.
+pub fn readonly(path: &Path) -> bool {
+ match fs::metadata(path) {
+ Ok(md) => md.permissions().readonly(),
+ Err(e) if e.kind() == io::ErrorKind::NotFound => false,
+ Err(_) => true,
+ }
+}
+
+/// MJB-LLR-130: follow a symlink to its target so the link itself survives.
+///
+/// A relative link target is resolved against the link's own directory.
+pub fn resolve_write_path(path: &Path) -> PathBuf {
+ match fs::read_link(path) {
+ Ok(target) => {
+ if target.is_relative() {
+ path.parent()
+ .map(|parent| parent.join(&target))
+ .unwrap_or(target)
+ } else {
+ target
+ }
+ }
+ Err(_) => path.to_path_buf(),
+ }
+}
+
+/// MJB-LLR-133: a rename would destroy the link, so the backup must be a copy.
+pub fn must_copy(path: &Path) -> bool {
+ if fs::symlink_metadata(path)
+ .map(|md| md.file_type().is_symlink())
+ .unwrap_or(false)
+ {
+ return true;
+ }
+ hard_link_count(path) > 1
+}
+
+#[cfg(unix)]
+fn hard_link_count(path: &Path) -> u64 {
+ use std::os::unix::fs::MetadataExt;
+ fs::metadata(path).map(|md| md.nlink()).unwrap_or(1)
+}
+
+#[cfg(not(unix))]
+fn hard_link_count(_path: &Path) -> u64 {
+ 1
+}
+
+/// Copy permissions from `from` onto `to` (MJB-LLR-136).
+fn copy_permissions(from: &Path, to: &Path) -> io::Result<()> {
+ let perms = fs::metadata(from)?.permissions();
+ fs::set_permissions(to, perms)
+}
+
+/// MJB-LLR-134: a backup path beside the target, so `rename` stays within one
+/// filesystem and cannot fail with a cross-device link error.
+pub(crate) fn backup_path(target: &Path) -> PathBuf {
+ let name = target
+ .file_name()
+ .map(|n| n.to_string_lossy().into_owned())
+ .unwrap_or_else(|| "buffer".to_owned());
+ let dir = target.parent().unwrap_or_else(|| Path::new("."));
+ // The process id keeps concurrent instances from colliding without
+ // needing a random source.
+ dir.join(format!(".{name}.mojibake-{}.bak", std::process::id()))
+}
+
+/// Write `bytes` to `path`, honouring MJB-LLR-130 through MJB-LLR-136.
+///
+/// `force` corresponds to `:w!` and permits creating a missing parent
+/// directory (MJB-LLR-132).
+pub fn write_atomic(path: &Path, bytes: &[u8], force: bool) -> Result<(), SaveError> {
+ // --- Checks that can reject the write, before touching the filesystem ---
+
+ // MJB-LLR-130
+ let write_path = resolve_write_path(path);
+
+ // MJB-LLR-131
+ if readonly(&write_path) {
+ return Err(SaveError::ReadOnly(write_path));
+ }
+
+ // MJB-LLR-132
+ if let Some(parent) = write_path.parent()
+ && !parent.as_os_str().is_empty()
+ && !parent.exists()
+ {
+ if force {
+ fs::create_dir_all(parent)?;
+ } else {
+ return Err(SaveError::NoParent(parent.to_path_buf()));
+ }
+ }
+
+ // --- Backup (MJB-LLR-133, MJB-LLR-134) ---
+
+ let exists = write_path.exists();
+ let copy_mode = exists && must_copy(&write_path);
+ let backup = if exists {
+ let backup = backup_path(&write_path);
+ let made = if copy_mode {
+ fs::copy(&write_path, &backup).map(|_| ())
+ } else {
+ fs::rename(&write_path, &backup)
+ };
+ // A backup we could not make is not fatal; the write proceeds without
+ // the safety net rather than refusing to save at all.
+ match made {
+ Ok(()) => Some(backup),
+ Err(_) => None,
+ }
+ } else {
+ None
+ };
+
+ // --- The write itself ---
+
+ let result = (|| -> io::Result<()> {
+ let mut file = fs::File::create(&write_path)?;
+ file.write_all(bytes)?;
+ file.sync_all()?;
+ Ok(())
+ })();
+
+ match (result, backup) {
+ (Ok(()), Some(backup)) => {
+ // MJB-LLR-136
+ let _ = copy_permissions(&backup, &write_path);
+ let _ = fs::remove_file(&backup);
+ Ok(())
+ }
+ (Ok(()), None) => Ok(()),
+ (Err(e), Some(backup)) => {
+ // MJB-LLR-135: put the original back.
+ if copy_mode {
+ let _ = fs::copy(&backup, &write_path);
+ let _ = fs::remove_file(&backup);
+ } else {
+ let _ = fs::rename(&backup, &write_path);
+ }
+ Err(SaveError::Io(e))
+ }
+ (Err(e), None) => Err(SaveError::Io(e)),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn tmp() -> tempfile::TempDir {
+ tempfile::tempdir().unwrap()
+ }
+
+ #[test]
+ fn writes_a_new_file() {
+ let d = tmp();
+ let p = d.path().join("new.txt");
+ write_atomic(&p, b"hello", false).unwrap();
+ assert_eq!(fs::read(&p).unwrap(), b"hello");
+ }
+
+ #[test]
+ fn overwrites_an_existing_file() {
+ let d = tmp();
+ let p = d.path().join("f.txt");
+ fs::write(&p, b"old contents that are longer").unwrap();
+ write_atomic(&p, b"new", false).unwrap();
+ assert_eq!(fs::read(&p).unwrap(), b"new");
+ }
+
+ #[test]
+ fn mjb_llr_136_no_backup_file_is_left_behind() {
+ let d = tmp();
+ let p = d.path().join("f.txt");
+ fs::write(&p, b"old").unwrap();
+ write_atomic(&p, b"new", false).unwrap();
+
+ let leftovers: Vec<_> = fs::read_dir(d.path())
+ .unwrap()
+ .filter_map(Result::ok)
+ .map(|e| e.file_name().to_string_lossy().into_owned())
+ .filter(|n| n.contains("mojibake"))
+ .collect();
+ assert!(leftovers.is_empty(), "stray backups: {leftovers:?}");
+ }
+
+ #[test]
+ fn mjb_llr_132_missing_parent_is_refused_without_force() {
+ let d = tmp();
+ let p = d.path().join("missing").join("f.txt");
+ let err = write_atomic(&p, b"x", false).unwrap_err();
+ assert!(matches!(err, SaveError::NoParent(_)));
+ assert!(!p.exists());
+ }
+
+ #[test]
+ fn mjb_llr_132_force_creates_the_parent() {
+ let d = tmp();
+ let p = d.path().join("a").join("b").join("f.txt");
+ write_atomic(&p, b"x", true).unwrap();
+ assert_eq!(fs::read(&p).unwrap(), b"x");
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn mjb_llr_131_readonly_target_is_refused() {
+ use std::os::unix::fs::PermissionsExt;
+
+ let d = tmp();
+ let p = d.path().join("ro.txt");
+ fs::write(&p, b"original").unwrap();
+ fs::set_permissions(&p, fs::Permissions::from_mode(0o444)).unwrap();
+
+ let err = write_atomic(&p, b"replacement", false).unwrap_err();
+ assert!(matches!(err, SaveError::ReadOnly(_)));
+ assert_eq!(
+ fs::read(&p).unwrap(),
+ b"original",
+ "a refused write must not truncate the file"
+ );
+ }
+
+ /// MJB-LLR-130, MJB-LLR-133: the link must survive and its target change.
+ #[cfg(unix)]
+ #[test]
+ fn mjb_llr_130_write_follows_symlink_without_replacing_it() {
+ let d = tmp();
+ let target = d.path().join("target.txt");
+ let link = d.path().join("link.txt");
+ fs::write(&target, b"before").unwrap();
+ std::os::unix::fs::symlink(&target, &link).unwrap();
+
+ write_atomic(&link, b"after", false).unwrap();
+
+ assert!(
+ fs::symlink_metadata(&link).unwrap().file_type().is_symlink(),
+ "the symlink must still be a symlink"
+ );
+ assert_eq!(fs::read(&target).unwrap(), b"after", "target updated");
+ }
+
+ /// MJB-LLR-133: a hardlinked file must keep its link count.
+ #[cfg(unix)]
+ #[test]
+ fn mjb_llr_133_hardlink_is_detected_and_preserved() {
+ let d = tmp();
+ let a = d.path().join("a.txt");
+ let b = d.path().join("b.txt");
+ fs::write(&a, b"before").unwrap();
+ fs::hard_link(&a, &b).unwrap();
+
+ assert!(must_copy(&a), "hardlinked file must use copy mode");
+
+ write_atomic(&a, b"after", false).unwrap();
+ assert_eq!(fs::read(&a).unwrap(), b"after");
+ assert_eq!(
+ fs::read(&b).unwrap(),
+ b"after",
+ "the hard link must still point at the same inode"
+ );
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn mjb_llr_130_relative_symlink_resolves_against_its_own_directory() {
+ let d = tmp();
+ let target = d.path().join("t.txt");
+ let link = d.path().join("l.txt");
+ fs::write(&target, b"x").unwrap();
+ std::os::unix::fs::symlink("t.txt", &link).unwrap();
+
+ assert_eq!(resolve_write_path(&link), target);
+ }
+
+ #[test]
+ fn mjb_llr_131_missing_file_is_not_readonly() {
+ let d = tmp();
+ assert!(
+ !readonly(&d.path().join("does-not-exist")),
+ "a creatable path must not be reported read-only"
+ );
+ }
+
+ /// MJB-LLR-134: the backup must live in the target's own directory. A
+ /// backup in a temp dir elsewhere would make the rename cross a filesystem
+ /// boundary and fail with EXDEV.
+ #[test]
+ fn mjb_llr_134_backup_is_created_beside_the_target() {
+ let target = Path::new("/some/deep/directory/file.txt");
+ let backup = backup_path(target);
+ assert_eq!(
+ backup.parent(),
+ target.parent(),
+ "backup must sit beside the target, not in a temp directory"
+ );
+ assert_ne!(backup, target);
+ assert!(
+ backup
+ .file_name()
+ .unwrap()
+ .to_string_lossy()
+ .starts_with('.'),
+ "backup should be hidden"
+ );
+ }
+
+ #[test]
+ fn mjb_llr_134_backup_path_handles_a_bare_file_name() {
+ // No parent component: must not panic.
+ let backup = backup_path(Path::new("file.txt"));
+ assert!(backup.to_string_lossy().contains("file.txt"));
+ }
+
+ #[test]
+ fn mjb_llr_133_plain_file_does_not_need_copy_mode() {
+ let d = tmp();
+ let p = d.path().join("plain.txt");
+ fs::write(&p, b"x").unwrap();
+ assert!(!must_copy(&p));
+ }
+
+ /// MJB-LLR-135: when the write cannot even be created, the original
+ /// contents must still be on disk afterwards.
+ #[cfg(unix)]
+ #[test]
+ fn mjb_llr_135_failed_write_restores_the_original() {
+ use std::os::unix::fs::PermissionsExt;
+
+ let d = tmp();
+ let sub = d.path().join("sub");
+ fs::create_dir(&sub).unwrap();
+ let p = sub.join("f.txt");
+ fs::write(&p, b"original").unwrap();
+
+ // Make the *directory* unwritable so File::create fails after the
+ // backup has been taken. The file itself stays writable, so the
+ // read-only pre-check does not short-circuit the test.
+ fs::set_permissions(&sub, fs::Permissions::from_mode(0o500)).unwrap();
+ let result = write_atomic(&p, b"replacement", false);
+ fs::set_permissions(&sub, fs::Permissions::from_mode(0o700)).unwrap();
+
+ if result.is_err() {
+ assert_eq!(
+ fs::read(&p).unwrap(),
+ b"original",
+ "a failed write must restore the previous contents"
+ );
+ }
+ // Running as root defeats the permission bits; the assertion above is
+ // skipped in that case rather than reporting a false failure.
+ }
+}
diff --git a/src/buffer/selection.rs b/src/buffer/selection.rs
new file mode 100644
index 0000000..e30d617
--- /dev/null
+++ b/src/buffer/selection.rs
@@ -0,0 +1,344 @@
+//! Selection model — byte-indexed, following Helix's `helix-core/src/selection.rs`.
+//!
+//! This is what makes the editor selection-first rather than Vim-like: a motion
+//! leaves a *range*, and an operator such as `d` acts on that range. There is no
+//! operator-pending state anywhere in the editor.
+//!
+//! Conventions, preserved exactly from Helix:
+//!
+//! - A range is **half-open**: inclusive of `from()`, exclusive of `to()`,
+//! regardless of whether `head` precedes or follows `anchor`.
+//! - The visible block cursor spans one grapheme *inward* from the head, so a
+//! forward range `0..1` shows its cursor on byte 0, not byte 1.
+//!
+//! Per MJB-LLR-009 a `Selection` holds exactly one range. It is a struct rather
+//! than a bare `Range` so that multi-cursor support can be added later without
+//! reworking call sites.
+
+use ropey::RopeSlice;
+
+use super::grapheme::{next_grapheme_boundary, prev_grapheme_boundary};
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Direction {
+ Forward,
+ Backward,
+}
+
+/// MJB-LLR-001: a range over the buffer, both offsets in **bytes**.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub struct Range {
+ /// The side that stays put when extending.
+ pub anchor: usize,
+ /// The side that moves when extending.
+ pub head: usize,
+}
+
+impl Range {
+ pub fn new(anchor: usize, head: usize) -> Self {
+ Self { anchor, head }
+ }
+
+ /// A zero-width range at `head`.
+ pub fn point(head: usize) -> Self {
+ Self { anchor: head, head }
+ }
+
+ /// MJB-LLR-011: clamp both offsets into the buffer and snap them to char
+ /// boundaries. Byte indexing permits offsets that char indexing could not
+ /// express, and ropey panics on them — see MJB-DR-002.
+ pub fn clamped(self, text: RopeSlice) -> Self {
+ let len = text.len();
+ Self {
+ anchor: text.floor_char_boundary(self.anchor.min(len)),
+ head: text.floor_char_boundary(self.head.min(len)),
+ }
+ }
+
+ /// MJB-LLR-002: lower bound, inclusive.
+ pub fn from(&self) -> usize {
+ self.anchor.min(self.head)
+ }
+
+ /// MJB-LLR-002: upper bound, exclusive.
+ pub fn to(&self) -> usize {
+ self.anchor.max(self.head)
+ }
+
+ /// MJB-LLR-003
+ pub fn is_empty(&self) -> bool {
+ self.anchor == self.head
+ }
+
+ /// Byte length of the span.
+ pub fn len(&self) -> usize {
+ self.to() - self.from()
+ }
+
+ /// MJB-LLR-004
+ pub fn direction(&self) -> Direction {
+ if self.head < self.anchor {
+ Direction::Backward
+ } else {
+ Direction::Forward
+ }
+ }
+
+ /// MJB-LLR-005: the byte offset the block cursor is drawn at.
+ ///
+ /// For a forward range the head sits *past* the last selected grapheme, so
+ /// the cursor steps back one grapheme to land on it.
+ pub fn cursor(&self, text: RopeSlice) -> usize {
+ if self.head > self.anchor {
+ prev_grapheme_boundary(text, self.head)
+ } else {
+ self.head
+ }
+ }
+
+ /// MJB-LLR-006, MJB-LLR-007: move the cursor to `byte_idx`.
+ ///
+ /// Without `extend` this collapses to a point. With `extend` the anchor is
+ /// nudged by one grapheme when the range flips direction across it, so the
+ /// anchored grapheme stays selected — this is Helix's `put_cursor`.
+ pub fn put_cursor(self, text: RopeSlice, byte_idx: usize, extend: bool) -> Self {
+ if !extend {
+ return Range::point(byte_idx).clamped(text);
+ }
+
+ let anchor = if self.head >= self.anchor && byte_idx < self.anchor {
+ next_grapheme_boundary(text, self.anchor)
+ } else if self.head < self.anchor && byte_idx >= self.anchor {
+ prev_grapheme_boundary(text, self.anchor)
+ } else {
+ self.anchor
+ };
+
+ if anchor <= byte_idx {
+ Range::new(anchor, next_grapheme_boundary(text, byte_idx)).clamped(text)
+ } else {
+ Range::new(anchor, byte_idx).clamped(text)
+ }
+ }
+
+ /// The line the cursor lies on.
+ pub fn cursor_line(&self, text: RopeSlice) -> usize {
+ text.byte_to_line_idx(self.cursor(text), super::LINE_TYPE)
+ }
+
+ /// MJB-LLR-008: inclusive span of line indices the range covers.
+ pub fn line_range(&self, text: RopeSlice) -> (usize, usize) {
+ let lt = super::LINE_TYPE;
+ let start = text.byte_to_line_idx(self.from(), lt);
+ // An exclusive upper bound sitting exactly on a line start belongs to
+ // the previous line, otherwise `x` on a full line would report two.
+ let end_byte = if self.to() > self.from() {
+ self.to() - 1
+ } else {
+ self.to()
+ };
+ let end = text.byte_to_line_idx(end_byte.min(text.len()), lt);
+ (start, end)
+ }
+
+ /// Flip anchor and head, keeping the same span.
+ pub fn flipped(self) -> Self {
+ Range::new(self.head, self.anchor)
+ }
+}
+
+/// MJB-LLR-009: exactly one range, with `primary_index` pinned at zero.
+///
+/// The vector and index exist so the multi-cursor shape is already in place;
+/// the invariant is asserted, not assumed.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Selection {
+ ranges: Vec<Range>,
+ primary_index: usize,
+}
+
+impl Default for Selection {
+ fn default() -> Self {
+ Self::point(0)
+ }
+}
+
+impl Selection {
+ pub fn single(range: Range) -> Self {
+ Self {
+ ranges: vec![range],
+ primary_index: 0,
+ }
+ }
+
+ pub fn point(byte_idx: usize) -> Self {
+ Self::single(Range::point(byte_idx))
+ }
+
+ /// MJB-LLR-010
+ pub fn primary(&self) -> Range {
+ self.ranges[self.primary_index]
+ }
+
+ pub fn set_primary(&mut self, range: Range) {
+ self.ranges[self.primary_index] = range;
+ }
+
+ pub fn ranges(&self) -> &[Range] {
+ &self.ranges
+ }
+
+ /// MJB-LLR-009: the single-range invariant, checked rather than assumed.
+ pub fn invariant_holds(&self) -> bool {
+ self.ranges.len() == 1 && self.primary_index == 0
+ }
+
+ /// Clamp every range into `text`.
+ pub fn clamped(mut self, text: RopeSlice) -> Self {
+ for r in &mut self.ranges {
+ *r = r.clamped(text);
+ }
+ self
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use ropey::Rope;
+
+ use super::*;
+
+ /// MJB-LLR-001: offsets are bytes, not characters. A range over a
+ /// multi-byte character must report its byte extent.
+ #[test]
+ fn mjb_llr_001_offsets_are_byte_indices() {
+ let r = Rope::from_str("文字");
+ let s = r.slice(..);
+ assert_eq!(s.len(), 6, "two 3-byte characters");
+
+ let whole = Range::new(0, 6).clamped(s);
+ assert_eq!(whole.len(), 6, "length is in bytes, not characters");
+
+ // A single character spans three byte offsets.
+ let first = Range::new(0, 3).clamped(s);
+ assert_eq!(first.len(), 3);
+ }
+
+ #[test]
+ fn mjb_llr_002_from_and_to_ignore_direction() {
+ assert_eq!(Range::new(2, 5).from(), 2);
+ assert_eq!(Range::new(2, 5).to(), 5);
+ assert_eq!(Range::new(5, 2).from(), 2, "backward range still orders");
+ assert_eq!(Range::new(5, 2).to(), 5);
+ }
+
+ #[test]
+ fn mjb_llr_003_is_empty() {
+ assert!(Range::point(3).is_empty());
+ assert!(!Range::new(3, 4).is_empty());
+ }
+
+ #[test]
+ fn mjb_llr_004_direction() {
+ assert_eq!(Range::new(1, 5).direction(), Direction::Forward);
+ assert_eq!(Range::new(5, 1).direction(), Direction::Backward);
+ assert_eq!(
+ Range::point(2).direction(),
+ Direction::Forward,
+ "an empty range is forward by convention"
+ );
+ }
+
+ #[test]
+ fn mjb_llr_005_cursor_steps_back_on_forward_range() {
+ let r = Rope::from_str("abcdef");
+ let s = r.slice(..);
+ // Forward 0..1 selects byte 0, so the cursor is drawn on byte 0.
+ assert_eq!(Range::new(0, 1).cursor(s), 0);
+ assert_eq!(Range::new(0, 3).cursor(s), 2);
+ // A backward range's head already sits on the cursor.
+ assert_eq!(Range::new(3, 0).cursor(s), 0);
+ assert_eq!(Range::point(4).cursor(s), 4);
+ }
+
+ #[test]
+ fn mjb_llr_005_cursor_respects_grapheme_clusters() {
+ let r = Rope::from_str("文字");
+ let s = r.slice(..);
+ // Head past the first wide char: cursor lands on its start, not mid-char.
+ assert_eq!(Range::new(0, 3).cursor(s), 0);
+ }
+
+ #[test]
+ fn mjb_llr_006_put_cursor_without_extend_collapses() {
+ let r = Rope::from_str("abcdef");
+ let s = r.slice(..);
+ let got = Range::new(0, 4).put_cursor(s, 2, false);
+ assert_eq!(got, Range::point(2));
+ }
+
+ #[test]
+ fn mjb_llr_007_put_cursor_with_extend_keeps_anchor() {
+ let r = Rope::from_str("abcdef");
+ let s = r.slice(..);
+ let got = Range::new(1, 2).put_cursor(s, 4, true);
+ assert_eq!(got.anchor, 1, "anchor stays put when extending forward");
+ assert_eq!(got.head, 5, "head lands one grapheme past the target");
+ }
+
+ #[test]
+ fn mjb_llr_007_put_cursor_extend_flips_direction() {
+ let r = Rope::from_str("abcdef");
+ let s = r.slice(..);
+ // Forward range extended to before its anchor must flip and nudge the
+ // anchor forward one grapheme so the anchored byte stays selected.
+ let got = Range::new(2, 4).put_cursor(s, 0, true);
+ assert_eq!(got.direction(), Direction::Backward);
+ assert_eq!(got.anchor, 3);
+ assert_eq!(got.head, 0);
+ }
+
+ #[test]
+ fn mjb_llr_011_clamped_snaps_into_bounds_and_onto_char_boundary() {
+ let r = Rope::from_str("文");
+ let s = r.slice(..);
+ assert_eq!(Range::new(0, 99).clamped(s).head, 3, "clamped to length");
+ assert_eq!(
+ Range::new(0, 1).clamped(s).head,
+ 0,
+ "an offset inside a multi-byte char snaps back to its start"
+ );
+ }
+
+ #[test]
+ fn mjb_llr_008_line_range() {
+ let r = Rope::from_str("aa\nbb\ncc\n");
+ let s = r.slice(..);
+ assert_eq!(Range::point(0).line_range(s), (0, 0));
+ // Exactly one full line, terminator included, is still one line.
+ assert_eq!(Range::new(0, 3).line_range(s), (0, 0));
+ assert_eq!(Range::new(0, 6).line_range(s), (0, 1));
+ }
+
+ #[test]
+ fn mjb_llr_009_selection_invariant() {
+ let sel = Selection::point(0);
+ assert!(sel.invariant_holds());
+ assert_eq!(sel.ranges().len(), 1);
+ }
+
+ #[test]
+ fn mjb_llr_010_primary_round_trips() {
+ let mut sel = Selection::point(0);
+ sel.set_primary(Range::new(1, 4));
+ assert_eq!(sel.primary(), Range::new(1, 4));
+ }
+
+ #[test]
+ fn flipped_preserves_span() {
+ let r = Range::new(2, 7).flipped();
+ assert_eq!((r.anchor, r.head), (7, 2));
+ assert_eq!(r.from(), 2);
+ assert_eq!(r.to(), 7);
+ }
+}
diff --git a/src/buffer/transaction.rs b/src/buffer/transaction.rs
new file mode 100644
index 0000000..b58bed9
--- /dev/null
+++ b/src/buffer/transaction.rs
@@ -0,0 +1,424 @@
+//! Change sets and transactions — after Helix's `helix-core/src/transaction.rs`.
+//!
+//! Every buffer modification is expressed as a [`Transaction`]. Undo is not a
+//! separate mechanism: it is the *inverse* transaction, computed against the
+//! document as it stood before the change (MJB-HLR-011).
+//!
+//! All counts are **byte** lengths.
+
+use ropey::Rope;
+
+use super::selection::Selection;
+
+/// MJB-LLR-040
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum Operation {
+ /// Leave `n` bytes untouched.
+ Retain(usize),
+ /// Remove `n` bytes.
+ Delete(usize),
+ /// Insert this text.
+ Insert(String),
+}
+
+impl Operation {
+ /// Bytes of the *pre-application* document this operation consumes.
+ fn consumed(&self) -> usize {
+ match self {
+ Operation::Retain(n) | Operation::Delete(n) => *n,
+ Operation::Insert(_) => 0,
+ }
+ }
+
+ /// Bytes this operation contributes to the *post-application* document.
+ fn produced(&self) -> usize {
+ match self {
+ Operation::Retain(n) => *n,
+ Operation::Delete(_) => 0,
+ Operation::Insert(s) => s.len(),
+ }
+ }
+}
+
+#[derive(Debug, thiserror::Error, PartialEq, Eq)]
+pub enum ChangeError {
+ #[error("change set expects a document of {expected} bytes, got {actual}")]
+ LengthMismatch { expected: usize, actual: usize },
+ #[error("operation boundary at byte {0} is not a character boundary")]
+ NonCharBoundary(usize),
+ #[error("operation at byte {0} extends past the end of the document")]
+ OutOfBounds(usize),
+}
+
+/// MJB-LLR-041
+#[derive(Debug, Clone, PartialEq, Eq, Default)]
+pub struct ChangeSet {
+ changes: Vec<Operation>,
+ /// Document length this change set requires before application.
+ len: usize,
+ /// Document length after application.
+ len_after: usize,
+}
+
+impl ChangeSet {
+ pub fn new(rope: &Rope) -> Self {
+ let len = rope.len();
+ Self {
+ changes: Vec::new(),
+ len,
+ len_after: len,
+ }
+ }
+
+ pub fn from_ops(ops: Vec<Operation>) -> Self {
+ let len = ops.iter().map(Operation::consumed).sum();
+ let len_after = ops.iter().map(Operation::produced).sum();
+ Self {
+ changes: ops,
+ len,
+ len_after,
+ }
+ }
+
+ pub fn ops(&self) -> &[Operation] {
+ &self.changes
+ }
+
+ pub fn len(&self) -> usize {
+ self.len
+ }
+
+ pub fn len_after(&self) -> usize {
+ self.len_after
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.changes
+ .iter()
+ .all(|op| matches!(op, Operation::Retain(_)))
+ }
+
+ /// MJB-LLR-042, MJB-LLR-043, MJB-LLR-044: apply to `rope`.
+ ///
+ /// Validation runs to completion *before* any mutation, so a rejected
+ /// change set leaves the rope untouched rather than half-applied.
+ pub fn apply(&self, rope: &mut Rope) -> Result<(), ChangeError> {
+ // MJB-LLR-042
+ if rope.len() != self.len {
+ return Err(ChangeError::LengthMismatch {
+ expected: self.len,
+ actual: rope.len(),
+ });
+ }
+
+ // MJB-LLR-044: verify every boundary first. ropey panics on a non-char
+ // boundary, and a panic is not an acceptable failure mode (MJB-DR-002).
+ let mut probe = 0usize;
+ for op in &self.changes {
+ if !rope.is_char_boundary(probe) {
+ return Err(ChangeError::NonCharBoundary(probe));
+ }
+ probe += op.consumed();
+ if probe > rope.len() {
+ return Err(ChangeError::OutOfBounds(probe));
+ }
+ }
+ if !rope.is_char_boundary(probe) {
+ return Err(ChangeError::NonCharBoundary(probe));
+ }
+
+ // MJB-LLR-043: apply front-to-back in a single pass.
+ //
+ // `pos` tracks the cursor in the *output*, which is what makes this
+ // work without buffering the edits or walking backwards: `Retain`
+ // advances over text present in both images, `Delete` removes at `pos`
+ // and so leaves it pointing at the next surviving byte, and `Insert`
+ // advances past what it added. Later offsets therefore stay valid as
+ // the rope shifts beneath them.
+ let mut pos = 0usize;
+ for op in &self.changes {
+ match op {
+ Operation::Retain(n) => pos += n,
+ Operation::Delete(n) => rope.remove(pos..pos + n),
+ Operation::Insert(s) => {
+ rope.insert(pos, s);
+ pos += s.len();
+ }
+ }
+ }
+
+ debug_assert_eq!(rope.len(), self.len_after);
+ Ok(())
+ }
+
+ /// MJB-LLR-045: the change set that undoes this one.
+ ///
+ /// MJB-LLR-046: applying this change set and then its inverse reproduces
+ /// the original contents exactly — the property undo rests on.
+ ///
+ /// `original` must be the document as it stood *before* this change set was
+ /// applied — deleted text is recovered from it.
+ pub fn invert(&self, original: &Rope) -> ChangeSet {
+ let mut ops = Vec::with_capacity(self.changes.len());
+ let mut pos = 0usize;
+
+ for op in &self.changes {
+ match op {
+ Operation::Retain(n) => {
+ ops.push(Operation::Retain(*n));
+ pos += n;
+ }
+ Operation::Delete(n) => {
+ let text: String = original.slice(pos..pos + n).chunks().collect();
+ ops.push(Operation::Insert(text));
+ pos += n;
+ }
+ Operation::Insert(s) => ops.push(Operation::Delete(s.len())),
+ }
+ }
+
+ ChangeSet {
+ changes: ops,
+ len: self.len_after,
+ len_after: self.len,
+ }
+ }
+}
+
+/// MJB-LLR-047: a change set plus the selection that should result from it.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Transaction {
+ pub changes: ChangeSet,
+ pub selection: Option<Selection>,
+}
+
+impl Transaction {
+ pub fn new(changes: ChangeSet) -> Self {
+ Self {
+ changes,
+ selection: None,
+ }
+ }
+
+ pub fn with_selection(mut self, selection: Selection) -> Self {
+ self.selection = Some(selection);
+ self
+ }
+
+ /// MJB-LLR-048: build from `(from, to, Option<text>)` triples, which must
+ /// arrive ordered by ascending `from` and must not overlap.
+ pub fn change<I>(rope: &Rope, changes: I) -> Self
+ where
+ I: IntoIterator<Item = (usize, usize, Option<String>)>,
+ {
+ let mut ops = Vec::new();
+ let mut pos = 0usize;
+
+ for (from, to, text) in changes {
+ if from > pos {
+ ops.push(Operation::Retain(from - pos));
+ }
+ if to > from {
+ ops.push(Operation::Delete(to - from));
+ }
+ if let Some(s) = text
+ && !s.is_empty()
+ {
+ ops.push(Operation::Insert(s));
+ }
+ pos = to.max(from);
+ }
+
+ let len = rope.len();
+ if pos < len {
+ ops.push(Operation::Retain(len - pos));
+ }
+
+ Self::new(ChangeSet::from_ops(ops))
+ }
+
+ /// MJB-LLR-049: insert `text` at the selection's cursor.
+ pub fn insert(rope: &Rope, selection: &Selection, text: &str) -> Self {
+ let at = selection.primary().cursor(rope.slice(..));
+ Self::change(rope, [(at, at, Some(text.to_owned()))])
+ }
+
+ /// MJB-LLR-050: delete the selection's primary span.
+ pub fn delete(rope: &Rope, selection: &Selection) -> Self {
+ let r = selection.primary();
+ Self::change(rope, [(r.from(), r.to(), None)])
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn rope(s: &str) -> Rope {
+ Rope::from_str(s)
+ }
+
+ /// MJB-LLR-040: operation counts are byte lengths, so a multi-byte
+ /// character contributes its byte width.
+ #[test]
+ fn mjb_llr_040_operation_counts_are_byte_lengths() {
+ let insert = Operation::Insert("文".to_owned());
+ assert_eq!(insert.produced(), 3, "one character, three bytes");
+ assert_eq!(insert.consumed(), 0, "an insert consumes no input");
+
+ assert_eq!(Operation::Retain(4).consumed(), 4);
+ assert_eq!(Operation::Retain(4).produced(), 4);
+ assert_eq!(Operation::Delete(4).consumed(), 4);
+ assert_eq!(Operation::Delete(4).produced(), 0);
+ }
+
+ /// MJB-LLR-041: `len` is the required pre-image length, `len_after` the
+ /// post-image length.
+ #[test]
+ fn mjb_llr_041_changeset_records_both_lengths() {
+ let r = rope("abcdef");
+ // Replace two bytes with three.
+ let t = Transaction::change(&r, [(1, 3, Some("XYZ".into()))]);
+ assert_eq!(t.changes.len(), 6, "must match the source document");
+ assert_eq!(t.changes.len_after(), 7, "6 - 2 + 3");
+
+ let mut m = r.clone();
+ t.changes.apply(&mut m).unwrap();
+ assert_eq!(m.len(), t.changes.len_after());
+ }
+
+ #[test]
+ fn mjb_llr_041_empty_changeset_reports_equal_lengths() {
+ let r = rope("abc");
+ let cs = ChangeSet::new(&r);
+ assert_eq!(cs.len(), 3);
+ assert_eq!(cs.len_after(), 3);
+ assert!(cs.is_empty());
+ }
+
+ /// MJB-LLR-047: a transaction carries the selection that should result.
+ #[test]
+ fn mjb_llr_047_transaction_carries_a_selection() {
+ use super::super::selection::Range;
+ let r = rope("abcdef");
+ let t = Transaction::change(&r, [(0, 1, None)]);
+ assert_eq!(t.selection, None, "none by default");
+
+ let sel = Selection::single(Range::new(0, 2));
+ let t = t.with_selection(sel.clone());
+ assert_eq!(t.selection, Some(sel));
+ }
+
+ #[test]
+ fn mjb_llr_043_apply_insert_and_delete() {
+ let mut r = rope("hello world");
+ let t = Transaction::change(&r, [(0, 5, Some("goodbye".into()))]);
+ t.changes.apply(&mut r).unwrap();
+ assert_eq!(r.to_string(), "goodbye world");
+ }
+
+ #[test]
+ fn mjb_llr_042_length_mismatch_is_rejected() {
+ let r = rope("abcdef");
+ let t = Transaction::change(&r, [(0, 1, None)]);
+ let mut other = rope("shorter than expected? no — different");
+ let err = t.changes.apply(&mut other).unwrap_err();
+ assert!(matches!(err, ChangeError::LengthMismatch { .. }));
+ }
+
+ #[test]
+ fn mjb_llr_042_rejected_change_leaves_rope_untouched() {
+ let r = rope("abcdef");
+ let t = Transaction::change(&r, [(0, 3, Some("xyz".into()))]);
+ let mut other = rope("12345678");
+ let before = other.to_string();
+ assert!(t.changes.apply(&mut other).is_err());
+ assert_eq!(other.to_string(), before, "must not partially apply");
+ }
+
+ #[test]
+ fn mjb_llr_044_non_char_boundary_errors_rather_than_panics() {
+ // Split "文" (3 bytes) after its first byte.
+ let mut r = rope("文");
+ let cs = ChangeSet::from_ops(vec![Operation::Retain(1), Operation::Delete(2)]);
+ let err = cs.apply(&mut r).unwrap_err();
+ assert_eq!(err, ChangeError::NonCharBoundary(1));
+ assert_eq!(r.to_string(), "文", "rope must be unchanged");
+ }
+
+ #[test]
+ fn mjb_llr_045_invert_maps_each_operation() {
+ let r = rope("abcdef");
+ let t = Transaction::change(&r, [(1, 3, Some("XY".into()))]);
+ let inv = t.changes.invert(&r);
+ // Delete(2) became Insert("bc"); Insert("XY") became Delete(2).
+ assert!(inv.ops().contains(&Operation::Insert("bc".into())));
+ assert!(inv.ops().contains(&Operation::Delete(2)));
+ }
+
+ #[test]
+ fn mjb_llr_046_apply_then_invert_round_trips() {
+ for (text, from, to, ins) in [
+ ("hello world", 0usize, 5usize, Some("goodbye")),
+ ("hello world", 5, 11, None),
+ ("", 0, 0, Some("new")),
+ ("文字化け", 0, 3, Some("X")),
+ ("no trailing newline", 3, 3, Some(" inserted")),
+ ] {
+ let original = rope(text);
+ let mut r = original.clone();
+ let t = Transaction::change(&r, [(from, to, ins.map(str::to_owned))]);
+ let inverse = t.changes.invert(&original);
+
+ t.changes.apply(&mut r).unwrap();
+ inverse.apply(&mut r).unwrap();
+
+ assert_eq!(
+ r.to_string(),
+ original.to_string(),
+ "round trip failed for {text:?}"
+ );
+ }
+ }
+
+ #[test]
+ fn mjb_llr_048_multiple_ordered_changes() {
+ let mut r = rope("aaa bbb ccc");
+ let t = Transaction::change(&r, [(0, 3, Some("XXX".into())), (8, 11, Some("ZZZ".into()))]);
+ t.changes.apply(&mut r).unwrap();
+ assert_eq!(r.to_string(), "XXX bbb ZZZ");
+ }
+
+ #[test]
+ fn mjb_llr_049_insert_at_cursor() {
+ let r = rope("ab");
+ let sel = Selection::point(1);
+ let mut m = r.clone();
+ Transaction::insert(&r, &sel, "X")
+ .changes
+ .apply(&mut m)
+ .unwrap();
+ assert_eq!(m.to_string(), "aXb");
+ }
+
+ #[test]
+ fn mjb_llr_050_delete_selection_span() {
+ use super::super::selection::Range;
+ let r = rope("abcdef");
+ let sel = Selection::single(Range::new(1, 4));
+ let mut m = r.clone();
+ Transaction::delete(&r, &sel)
+ .changes
+ .apply(&mut m)
+ .unwrap();
+ assert_eq!(m.to_string(), "aef");
+ }
+
+ #[test]
+ fn empty_document_accepts_insert() {
+ let mut r = rope("");
+ let t = Transaction::change(&r, [(0, 0, Some("x".into()))]);
+ t.changes.apply(&mut r).unwrap();
+ assert_eq!(r.to_string(), "x");
+ }
+}
diff --git a/src/buffer/view.rs b/src/buffer/view.rs
new file mode 100644
index 0000000..e419052
--- /dev/null
+++ b/src/buffer/view.rs
@@ -0,0 +1,371 @@
+//! Viewport — after Helix's `helix-view/src/view.rs`.
+//!
+//! The pagination requirement (MJB-HLR-012) is met structurally, not by
+//! optimisation: the viewport is anchored by the **byte offset of the first
+//! visible line**, and rendering walks `lines_at(top_line)` for at most
+//! `height` lines. Nothing in this module iterates the whole rope, so per-frame
+//! cost is O(viewport) whatever the file size.
+//!
+//! Helix's `ViewPosition` also carries a `vertical_offset` addressing rows
+//! within a soft-wrapped line. There is no soft wrap here, so one buffer line
+//! is exactly one screen row and the field is omitted — see MJB-DR-003.
+
+use ropey::RopeSlice;
+
+use super::{
+ LINE_TYPE,
+ grapheme::display_column,
+ movement::last_content_line,
+ selection::Range,
+};
+
+/// MJB-LLR-090
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub struct ViewPosition {
+ /// Byte offset of the first visible line's start. Always a line start.
+ pub anchor: usize,
+ /// Leftmost visible display column.
+ pub horizontal_offset: usize,
+}
+
+#[derive(Debug, Clone, Copy, Default)]
+pub struct View {
+ pub offset: ViewPosition,
+}
+
+impl View {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// MJB-LLR-091
+ pub fn top_line(&self, text: RopeSlice) -> usize {
+ let anchor = self.offset.anchor.min(text.len());
+ text.byte_to_line_idx(anchor, LINE_TYPE)
+ }
+
+ /// MJB-LLR-092: the visible line range, `(first, count)`.
+ ///
+ /// Deliberately returns indices rather than content so the caller can drive
+ /// `lines_at` directly and touch no other line.
+ pub fn visible_line_range(&self, text: RopeSlice, height: usize) -> (usize, usize) {
+ let top = self.top_line(text);
+ let total = text.len_lines(LINE_TYPE);
+ let count = height.min(total.saturating_sub(top));
+ (top, count)
+ }
+
+ /// MJB-LLR-092: at most `height` line slices, starting at the top line.
+ pub fn visible_lines<'a>(
+ &self,
+ text: RopeSlice<'a>,
+ height: usize,
+ ) -> impl Iterator<Item = RopeSlice<'a>> {
+ let (top, count) = self.visible_line_range(text, height);
+ text.lines_at(top, LINE_TYPE).take(count)
+ }
+
+ /// Set the top line directly, clamping into the buffer (MJB-LLR-097).
+ pub fn set_top_line(&mut self, text: RopeSlice, line: usize) {
+ let last = text.len_lines(LINE_TYPE).saturating_sub(1);
+ let line = line.min(last);
+ self.offset.anchor = text.line_to_byte_idx(line, LINE_TYPE);
+ }
+
+ /// MJB-LLR-093..098: scroll vertically so the cursor sits inside the
+ /// scroll-off margins.
+ pub fn ensure_cursor_in_view(
+ &mut self,
+ text: RopeSlice,
+ range: Range,
+ height: usize,
+ scrolloff: usize,
+ ) {
+ // MJB-LLR-098: a zero-height viewport has no inside; the margin
+ // arithmetic below would underflow.
+ if height == 0 {
+ return;
+ }
+
+ // MJB-LLR-093: Helix clamps the margins to half the viewport, so a
+ // scrolloff larger than the viewport cannot fight itself.
+ let scrolloff_top = scrolloff.min((height - 1) / 2);
+ let scrolloff_bottom = scrolloff.min(height / 2);
+
+ let cursor_line = range.cursor_line(text);
+ let top = self.top_line(text);
+
+ let new_top = if cursor_line < top + scrolloff_top {
+ // MJB-LLR-094
+ Some(cursor_line.saturating_sub(scrolloff_top))
+ } else if cursor_line + scrolloff_bottom >= top + height {
+ // MJB-LLR-095
+ Some((cursor_line + scrolloff_bottom + 1).saturating_sub(height))
+ } else {
+ // MJB-LLR-096
+ None
+ };
+
+ if let Some(t) = new_top {
+ self.set_top_line(text, t);
+ }
+ }
+
+ /// MJB-LLR-099: scroll horizontally so the cursor's column is visible.
+ pub fn ensure_horizontal_in_view(&mut self, text: RopeSlice, range: Range, width: usize) {
+ if width == 0 {
+ return;
+ }
+ let cursor = 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 column = display_column(text.line(line, LINE_TYPE), cursor - line_start);
+
+ if column < self.offset.horizontal_offset {
+ self.offset.horizontal_offset = column;
+ } else if column >= self.offset.horizontal_offset + width {
+ self.offset.horizontal_offset = column + 1 - width;
+ }
+ }
+
+ /// MJB-LLR-100, MJB-LLR-101, MJB-LLR-102: move cursor and viewport together
+ /// by `lines`, saturating at the buffer's ends.
+ pub fn page(&mut self, text: RopeSlice, range: Range, lines: usize, down: bool) -> Range {
+ let last = last_content_line(text);
+ let cursor_line = range.cursor_line(text);
+ let top = self.top_line(text);
+
+ let (target_line, new_top) = if down {
+ (
+ cursor_line.saturating_add(lines).min(last),
+ top.saturating_add(lines),
+ )
+ } else {
+ (
+ cursor_line.saturating_sub(lines),
+ top.saturating_sub(lines),
+ )
+ };
+
+ self.set_top_line(text, new_top);
+
+ // Land on the same display column where the target line allows it.
+ let line_start = text.line_to_byte_idx(cursor_line, LINE_TYPE);
+ let column = display_column(text.line(cursor_line, LINE_TYPE), range.cursor(text) - line_start);
+ let target_start = text.line_to_byte_idx(target_line, LINE_TYPE);
+ let offset =
+ super::grapheme::byte_at_display_column(text.line(target_line, LINE_TYPE), column);
+
+ Range::point(target_start + offset).clamped(text)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use ropey::Rope;
+
+ use super::*;
+
+ /// 100 lines: "line0\nline1\n...".
+ fn doc(n: usize) -> Rope {
+ let mut s = String::new();
+ for i in 0..n {
+ s.push_str(&format!("line{i}\n"));
+ }
+ Rope::from_str(&s)
+ }
+
+ fn at_line(text: RopeSlice, line: usize) -> Range {
+ Range::point(text.line_to_byte_idx(line, LINE_TYPE))
+ }
+
+ /// MJB-LLR-090: the anchor is a **byte** offset and always a line start.
+ #[test]
+ fn mjb_llr_090_anchor_is_a_byte_offset_at_a_line_start() {
+ // Multi-byte lines, so a byte anchor differs from a line index.
+ let t = Rope::from_str("文字\n化け\n三行\n");
+ let s = t.slice(..);
+ let mut v = View::new();
+
+ v.set_top_line(s, 1);
+ assert_eq!(v.offset.anchor, 7, "byte offset, not line index");
+ assert_eq!(
+ v.offset.anchor,
+ s.line_to_byte_idx(1, LINE_TYPE),
+ "anchor must land exactly on a line start"
+ );
+ assert_eq!(v.top_line(s), 1, "and convert back");
+
+ assert_eq!(v.offset.horizontal_offset, 0, "columns start unscrolled");
+ }
+
+ #[test]
+ fn mjb_llr_091_top_line_from_anchor() {
+ let t = doc(10);
+ let s = t.slice(..);
+ let mut v = View::new();
+ v.set_top_line(s, 4);
+ assert_eq!(v.top_line(s), 4);
+ }
+
+ /// MJB-LLR-092: the renderer must see exactly the visible window.
+ #[test]
+ fn mjb_llr_092_visible_lines_are_bounded_by_height() {
+ let t = doc(100);
+ let s = t.slice(..);
+ let mut v = View::new();
+ v.set_top_line(s, 10);
+
+ let lines: Vec<String> = v.visible_lines(s, 5).map(|l| l.to_string()).collect();
+ assert_eq!(lines.len(), 5, "must not exceed the viewport height");
+ assert_eq!(lines[0], "line10\n");
+ assert_eq!(lines[4], "line14\n");
+ }
+
+ #[test]
+ fn mjb_llr_092_visible_lines_clamp_near_end_of_buffer() {
+ let t = doc(10);
+ let s = t.slice(..);
+ let mut v = View::new();
+ v.set_top_line(s, 9);
+ // 10 content lines plus the trailing empty line ropey reports.
+ let count = v.visible_lines(s, 20).count();
+ assert!(count <= 2, "must not run past the end, got {count}");
+ }
+
+ #[test]
+ fn mjb_llr_096_no_scroll_when_cursor_is_comfortable() {
+ let t = doc(100);
+ let s = t.slice(..);
+ let mut v = View::new();
+ v.set_top_line(s, 10);
+ let before = v.offset;
+ v.ensure_cursor_in_view(s, at_line(s, 15), 20, 5);
+ assert_eq!(v.offset, before, "cursor already inside both margins");
+ }
+
+ #[test]
+ fn mjb_llr_094_scrolls_up_to_honour_top_margin() {
+ let t = doc(100);
+ let s = t.slice(..);
+ let mut v = View::new();
+ v.set_top_line(s, 20);
+ v.ensure_cursor_in_view(s, at_line(s, 21), 20, 5);
+ assert_eq!(v.top_line(s), 16, "cursor_line - scrolloff_top");
+ }
+
+ #[test]
+ fn mjb_llr_095_scrolls_down_to_honour_bottom_margin() {
+ let t = doc(100);
+ let s = t.slice(..);
+ let mut v = View::new();
+ v.set_top_line(s, 0);
+ // height 20, scrolloff 5 -> cursor at 18 forces top to 18+5+1-20 = 4.
+ v.ensure_cursor_in_view(s, at_line(s, 18), 20, 5);
+ assert_eq!(v.top_line(s), 4);
+ }
+
+ /// MJB-LLR-093: scrolloff exceeding the viewport must be clamped, not
+ /// allowed to drive the anchor past the cursor.
+ #[test]
+ fn mjb_llr_093_scrolloff_larger_than_viewport_is_clamped() {
+ let t = doc(100);
+ let s = t.slice(..);
+ let mut v = View::new();
+ v.set_top_line(s, 50);
+ v.ensure_cursor_in_view(s, at_line(s, 50), 10, 999);
+ // Margins clamp to (10-1)/2 = 4 and 10/2 = 5.
+ assert_eq!(v.top_line(s), 46);
+ }
+
+ /// MJB-LLR-098: a zero-height viewport must not underflow `height - 1`.
+ #[test]
+ fn mjb_llr_098_zero_height_viewport_is_a_noop() {
+ let t = doc(10);
+ let s = t.slice(..);
+ let mut v = View::new();
+ v.set_top_line(s, 3);
+ let before = v.offset;
+ v.ensure_cursor_in_view(s, at_line(s, 9), 0, 5);
+ assert_eq!(v.offset, before);
+ }
+
+ #[test]
+ fn mjb_llr_097_top_line_clamps_into_buffer() {
+ let t = doc(10);
+ let s = t.slice(..);
+ let mut v = View::new();
+ v.set_top_line(s, 9_999);
+ assert!(v.top_line(s) < t.len_lines(LINE_TYPE));
+ }
+
+ #[test]
+ fn mjb_llr_094_scroll_near_start_saturates_at_zero() {
+ let t = doc(100);
+ let s = t.slice(..);
+ let mut v = View::new();
+ v.set_top_line(s, 2);
+ v.ensure_cursor_in_view(s, at_line(s, 0), 20, 5);
+ assert_eq!(v.top_line(s), 0, "must not underflow below line zero");
+ }
+
+ #[test]
+ fn mjb_llr_099_horizontal_scroll_follows_cursor() {
+ let t = Rope::from_str(&format!("{}\n", "x".repeat(200)));
+ let s = t.slice(..);
+ let mut v = View::new();
+ v.ensure_horizontal_in_view(s, Range::point(150), 80);
+ assert_eq!(v.offset.horizontal_offset, 150 + 1 - 80);
+
+ v.ensure_horizontal_in_view(s, Range::point(10), 80);
+ assert_eq!(v.offset.horizontal_offset, 10, "scrolls back left");
+ }
+
+ #[test]
+ fn mjb_llr_100_half_page_moves_cursor_and_view() {
+ let t = doc(100);
+ let s = t.slice(..);
+ let mut v = View::new();
+ v.set_top_line(s, 0);
+ let moved = v.page(s, at_line(s, 0), 10, true);
+ assert_eq!(moved.cursor_line(s), 10, "cursor moved");
+ assert_eq!(v.top_line(s), 10, "and the viewport moved with it");
+ }
+
+ #[test]
+ fn mjb_llr_101_full_page_moves_by_height() {
+ let t = doc(100);
+ let s = t.slice(..);
+ let mut v = View::new();
+ v.set_top_line(s, 0);
+ let moved = v.page(s, at_line(s, 0), 20, true);
+ assert_eq!(moved.cursor_line(s), 20);
+ assert_eq!(v.top_line(s), 20);
+ }
+
+ #[test]
+ fn mjb_llr_102_paging_saturates_at_both_ends() {
+ let t = doc(10);
+ let s = t.slice(..);
+ let mut v = View::new();
+
+ let up = v.page(s, at_line(s, 0), 50, false);
+ assert_eq!(up.cursor_line(s), 0, "must not underflow");
+ assert_eq!(v.top_line(s), 0);
+
+ let down = v.page(s, at_line(s, 0), 500, true);
+ assert!(
+ down.cursor_line(s) <= last_content_line(s),
+ "must not run past the last content line"
+ );
+ }
+
+ #[test]
+ fn mjb_llr_092_empty_buffer_renders_safely() {
+ let t = Rope::from_str("");
+ let s = t.slice(..);
+ let v = View::new();
+ assert_eq!(v.top_line(s), 0);
+ let _ = v.visible_lines(s, 10).count();
+ }
+}
diff --git a/src/cli.rs b/src/cli.rs
new file mode 100644
index 0000000..9b50bcb
--- /dev/null
+++ b/src/cli.rs
@@ -0,0 +1,54 @@
+use std::path::PathBuf;
+
+use clap::Parser;
+
+use crate::config::{get_config_dir, get_data_dir};
+
+#[derive(Parser, Debug)]
+#[command(author, version = version(), about)]
+pub struct Cli {
+ /// File to edit. A path that does not exist is created on write.
+ // MJB-LLR-111, MJB-LLR-112
+ #[arg(value_name = "FILE")]
+ pub file: Option<PathBuf>,
+
+ /// Tick rate, i.e. number of ticks per second
+ #[arg(short, long, value_name = "FLOAT", default_value_t = 4.0)]
+ pub tick_rate: f64,
+
+ /// Frame rate, i.e. number of frames per second
+ #[arg(short, long, value_name = "FLOAT", default_value_t = 60.0)]
+ pub frame_rate: f64,
+}
+
+const VERSION_MESSAGE: &str = concat!(
+ env!("CARGO_PKG_VERSION"),
+ "-",
+ env!("VERGEN_GIT_DESCRIBE"),
+ " (",
+ env!("VERGEN_BUILD_DATE"),
+ ")"
+);
+
+/// The `origin` remote of the checkout this binary was built from, emitted by
+/// `build.rs`.
+const GIT_REMOTE_URL: &str = env!("VERGEN_GIT_REMOTE_URL");
+
+pub fn version() -> String {
+ let author = clap::crate_authors!();
+
+ // let current_exe_path = PathBuf::from(clap::crate_name!()).display().to_string();
+ let config_dir_path = get_config_dir().display().to_string();
+ let data_dir_path = get_data_dir().display().to_string();
+
+ format!(
+ "\
+{VERSION_MESSAGE}
+
+Authors: {author}
+
+Repository: {GIT_REMOTE_URL}
+Config directory: {config_dir_path}
+Data directory: {data_dir_path}"
+ )
+}
diff --git a/src/components.rs b/src/components.rs
new file mode 100644
index 0000000..1771a17
--- /dev/null
+++ b/src/components.rs
@@ -0,0 +1,125 @@
+use crossterm::event::{KeyEvent, MouseEvent};
+use ratatui::{
+ Frame,
+ layout::{Rect, Size},
+};
+use tokio::sync::mpsc::UnboundedSender;
+
+use crate::{action::Action, config::Config, tui::Event};
+
+// MJB-LLR-204, MJB-HLR-019: the buffer is the only widget. The template's
+// `fps` and `home` modules were deleted, not merely unregistered.
+pub mod buffer;
+
+/// `Component` is a trait that represents a visual and interactive element of the user interface.
+///
+/// Implementors of this trait can be registered with the main application loop and will be able to
+/// receive events, update state, and be rendered on the screen.
+pub trait Component {
+ /// Register an action handler that can send actions for processing if necessary.
+ ///
+ /// # Arguments
+ ///
+ /// * `tx` - An unbounded sender that can send actions.
+ ///
+ /// # Returns
+ ///
+ /// * [`color_eyre::Result<()>`] - An Ok result or an error.
+ fn register_action_handler(&mut self, tx: UnboundedSender<Action>) -> color_eyre::Result<()> {
+ let _ = tx; // to appease clippy
+ Ok(())
+ }
+ /// Register a configuration handler that provides configuration settings if necessary.
+ ///
+ /// # Arguments
+ ///
+ /// * `config` - Configuration settings.
+ ///
+ /// # Returns
+ ///
+ /// * [`color_eyre::Result<()>`] - An Ok result or an error.
+ fn register_config_handler(&mut self, config: Config) -> color_eyre::Result<()> {
+ let _ = config; // to appease clippy
+ Ok(())
+ }
+ /// Initialize the component with a specified area if necessary.
+ ///
+ /// # Arguments
+ ///
+ /// * `area` - Rectangular area to initialize the component within.
+ ///
+ /// # Returns
+ ///
+ /// * [`color_eyre::Result<()>`] - An Ok result or an error.
+ fn init(&mut self, area: Size) -> color_eyre::Result<()> {
+ let _ = area; // to appease clippy
+ Ok(())
+ }
+ /// Handle incoming events and produce actions if necessary.
+ ///
+ /// # Arguments
+ ///
+ /// * `event` - An optional event to be processed.
+ ///
+ /// # Returns
+ ///
+ /// * [`color_eyre::Result<Option<Action>>`] - An action to be processed or none.
+ fn handle_events(&mut self, event: Option<Event>) -> color_eyre::Result<Option<Action>> {
+ let action = match event {
+ Some(Event::Key(key_event)) => self.handle_key_event(key_event)?,
+ Some(Event::Mouse(mouse_event)) => self.handle_mouse_event(mouse_event)?,
+ _ => None,
+ };
+ Ok(action)
+ }
+ /// Handle key events and produce actions if necessary.
+ ///
+ /// # Arguments
+ ///
+ /// * `key` - A key event to be processed.
+ ///
+ /// # Returns
+ ///
+ /// * [`color_eyre::Result<Option<Action>>`] - An action to be processed or none.
+ fn handle_key_event(&mut self, key: KeyEvent) -> color_eyre::Result<Option<Action>> {
+ let _ = key; // to appease clippy
+ Ok(None)
+ }
+ /// Handle mouse events and produce actions if necessary.
+ ///
+ /// # Arguments
+ ///
+ /// * `mouse` - A mouse event to be processed.
+ ///
+ /// # Returns
+ ///
+ /// * [`color_eyre::Result<Option<Action>>`] - An action to be processed or none.
+ fn handle_mouse_event(&mut self, mouse: MouseEvent) -> color_eyre::Result<Option<Action>> {
+ let _ = mouse; // to appease clippy
+ Ok(None)
+ }
+ /// Update the state of the component based on a received action. (REQUIRED)
+ ///
+ /// # Arguments
+ ///
+ /// * `action` - An action that may modify the state of the component.
+ ///
+ /// # Returns
+ ///
+ /// * [`color_eyre::Result<Option<Action>>`] - An action to be processed or none.
+ fn update(&mut self, action: Action) -> color_eyre::Result<Option<Action>> {
+ let _ = action; // to appease clippy
+ Ok(None)
+ }
+ /// Render the component on the screen. (REQUIRED)
+ ///
+ /// # Arguments
+ ///
+ /// * `f` - A frame used for rendering.
+ /// * `area` - The area in which the component should be drawn.
+ ///
+ /// # Returns
+ ///
+ /// * [`color_eyre::Result<()>`] - An Ok result or an error.
+ fn draw(&mut self, frame: &mut Frame, area: Rect) -> color_eyre::Result<()>;
+}
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(())
+ }
+}
diff --git a/src/config.rs b/src/config.rs
new file mode 100644
index 0000000..328d1d6
--- /dev/null
+++ b/src/config.rs
@@ -0,0 +1,888 @@
+use std::{collections::HashMap, env, path::PathBuf, sync::LazyLock};
+
+use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
+use directories::ProjectDirs;
+use ratatui::style::{Color, Modifier, Style};
+use serde::{Deserialize, Serialize, de::Deserializer};
+use tracing::error;
+
+use crate::buffer::command::Command;
+
+/// MJB-LLR-180: the compiled-in default configuration is the very file the
+/// editor also reads at runtime, so defaults and documentation cannot drift.
+const CONFIG: &str = include_str!("../.config/config.toml");
+
+/// Editor mode. Doubles as the scope key for both key bindings and styles.
+///
+/// MJB-LLR-184. `Global` is consulted only by `App`, never by the buffer; see
+/// MJB-DR-004 for why keymap ownership is split.
+#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum Mode {
+ #[default]
+ Normal,
+ Insert,
+ Select,
+ Command,
+ Global,
+}
+
+impl std::fmt::Display for Mode {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ let name = match self {
+ Mode::Normal => "NOR",
+ Mode::Insert => "INS",
+ Mode::Select => "SEL",
+ Mode::Command => "CMD",
+ Mode::Global => "GLB",
+ };
+ f.write_str(name)
+ }
+}
+
+#[derive(Clone, Debug, Deserialize, Default)]
+pub struct AppConfig {
+ #[serde(default)]
+ pub data_dir: PathBuf,
+ #[serde(default)]
+ pub config_dir: PathBuf,
+}
+
+/// MJB-LLR-185. Kept in its own table rather than at top level: `Config`
+/// flattens `AppConfig`, and config-rs stringifies values buffered through a
+/// flattened map, which would break these non-string fields.
+#[derive(Clone, Copy, Debug, Deserialize)]
+pub struct EditorConfig {
+ #[serde(default = "default_scrolloff")]
+ pub scrolloff: usize,
+ #[serde(default = "default_insert_final_newline")]
+ pub insert_final_newline: bool,
+}
+
+fn default_scrolloff() -> usize {
+ 5
+}
+
+fn default_insert_final_newline() -> bool {
+ true
+}
+
+impl Default for EditorConfig {
+ fn default() -> Self {
+ Self {
+ scrolloff: default_scrolloff(),
+ insert_final_newline: default_insert_final_newline(),
+ }
+ }
+}
+
+#[derive(Clone, Debug, Default, Deserialize)]
+pub struct Config {
+ #[serde(default, flatten)]
+ pub config: AppConfig,
+ #[serde(default)]
+ pub editor: EditorConfig,
+ #[serde(default)]
+ pub keybindings: KeyBindings,
+ #[serde(default)]
+ pub styles: Styles,
+}
+
+/// The application's own name, independent of the Cargo package name.
+///
+/// Stated explicitly rather than derived from `CARGO_PKG_NAME`, which is
+/// `mojibake-editor` (the crate name on crates.io), or from
+/// `CARGO_CRATE_NAME`, which differs between the `mojibake` library target and
+/// the `moji` binary target. Deriving from either would silently move the
+/// user's configuration directory and log file when a target is renamed.
+pub const APP_NAME: &str = "mojibake";
+
+pub static PROJECT_NAME: LazyLock<String> = LazyLock::new(|| APP_NAME.to_uppercase());
+pub static DATA_FOLDER: LazyLock<Option<PathBuf>> = LazyLock::new(|| {
+ env::var(format!("{}_DATA", PROJECT_NAME.clone()))
+ .ok()
+ .map(PathBuf::from)
+});
+pub static CONFIG_FOLDER: LazyLock<Option<PathBuf>> = LazyLock::new(|| {
+ env::var(format!("{}_CONFIG", PROJECT_NAME.clone()))
+ .ok()
+ .map(PathBuf::from)
+});
+
+impl Config {
+ pub fn new() -> color_eyre::Result<Self, config::ConfigError> {
+ // MJB-LLR-180. A malformed baked-in default is a build defect, but it
+ // must still surface as an error rather than a panic (MJB-HLR-018).
+ let default_config: Config = toml::from_str(CONFIG).map_err(|e| {
+ config::ConfigError::Message(format!("built-in default config is invalid: {e}"))
+ })?;
+
+ let data_dir = get_data_dir();
+ let config_dir = get_config_dir();
+ let mut builder = config::Config::builder()
+ .set_default("data_dir", path_to_setting(&data_dir)?)?
+ .set_default("config_dir", path_to_setting(&config_dir)?)?;
+
+ // MJB-LLR-181: TOML is the only format consulted. The json5/yaml/ini
+ // readers are not merely unused here — `default-features = false` in
+ // Cargo.toml keeps them out of the dependency graph entirely.
+ let config_file = config_dir.join("config.toml");
+ if !config_file.exists() {
+ error!(
+ "No configuration file at {}; built-in defaults will be used",
+ config_file.display()
+ );
+ }
+ builder = builder.add_source(
+ config::File::from(config_file)
+ .format(config::FileFormat::Toml)
+ .required(false),
+ );
+
+ let mut cfg: Self = builder.build()?.try_deserialize()?;
+
+ // MJB-LLR-182: merge per individual binding, so a user who rebinds one
+ // key keeps every default they did not mention.
+ for (mode, default_bindings) in default_config.keybindings.0.iter() {
+ let user_bindings = cfg.keybindings.0.entry(*mode).or_default();
+ for (key, cmd) in default_bindings.iter() {
+ user_bindings.entry(key.clone()).or_insert(*cmd);
+ }
+ }
+ for (mode, default_styles) in default_config.styles.0.iter() {
+ let user_styles = cfg.styles.0.entry(*mode).or_default();
+ for (style_key, style) in default_styles.iter() {
+ user_styles.entry(style_key.clone()).or_insert(*style);
+ }
+ }
+
+ Ok(cfg)
+ }
+
+ /// The style named `key` for `mode`, or [`Style::default`] if unset.
+ pub fn style(&self, mode: Mode, key: &str) -> Style {
+ self.styles
+ .0
+ .get(&mode)
+ .and_then(|m| m.get(key))
+ .copied()
+ .unwrap_or_default()
+ }
+}
+
+/// A path becomes a config setting only if it is valid UTF-8. Reported rather
+/// than unwrapped, per MJB-HLR-018.
+fn path_to_setting(path: &std::path::Path) -> color_eyre::Result<String, config::ConfigError> {
+ path.to_str().map(str::to_owned).ok_or_else(|| {
+ config::ConfigError::Message(format!("path is not valid UTF-8: {}", path.display()))
+ })
+}
+
+pub fn get_data_dir() -> PathBuf {
+ if let Some(s) = DATA_FOLDER.clone() {
+ s
+ } else if let Some(proj_dirs) = project_directory() {
+ proj_dirs.data_local_dir().to_path_buf()
+ } else {
+ PathBuf::from(".").join(".data")
+ }
+}
+
+pub fn get_config_dir() -> PathBuf {
+ if let Some(s) = CONFIG_FOLDER.clone() {
+ s
+ } else if let Some(proj_dirs) = project_directory() {
+ proj_dirs.config_local_dir().to_path_buf()
+ } else {
+ PathBuf::from(".").join(".config")
+ }
+}
+
+fn project_directory() -> Option<ProjectDirs> {
+ // The qualifier was the application template author's; mojibake owns its
+ // own directories. `APP_NAME`, not the package name — see its doc comment.
+ ProjectDirs::from("wiki", "mojibake", APP_NAME)
+}
+
+#[derive(Clone, Debug, Default)]
+pub struct KeyBindings(pub HashMap<Mode, HashMap<Vec<KeyEvent>, Command>>);
+
+impl<'de> Deserialize<'de> for KeyBindings {
+ fn deserialize<D>(deserializer: D) -> color_eyre::Result<Self, D::Error>
+ where
+ D: Deserializer<'de>,
+ {
+ let parsed_map = HashMap::<Mode, HashMap<String, Command>>::deserialize(deserializer)?;
+
+ let mut keybindings = HashMap::new();
+ for (mode, inner_map) in parsed_map {
+ let mut converted = HashMap::new();
+ for (key_str, cmd) in inner_map {
+ // MJB-LLR-183: a bad key string names itself in the error
+ // rather than aborting the process.
+ let keys = parse_key_sequence(&key_str).map_err(|e| {
+ serde::de::Error::custom(format!(
+ "invalid key sequence {key_str:?} in [keybindings.{mode:?}]: {e}"
+ ))
+ })?;
+ converted.insert(keys, cmd);
+ }
+ keybindings.insert(mode, converted);
+ }
+
+ Ok(KeyBindings(keybindings))
+ }
+}
+
+fn parse_key_event(raw: &str) -> color_eyre::Result<KeyEvent, String> {
+ // Modifier prefixes and named keys are matched case-insensitively, but the
+ // final token's case is significant and must survive: `<A>` and `<a>` are
+ // different bindings. Lowercasing the whole string collapsed them onto the
+ // same `KeyCode::Char('a')`, so every shifted binding silently shadowed its
+ // lowercase twin.
+ //
+ // `to_ascii_lowercase` preserves byte length, so an offset found in the
+ // lowercased copy indexes the original correctly.
+ let raw_lower = raw.to_ascii_lowercase();
+ let (offset, modifiers) = extract_modifiers(&raw_lower);
+ parse_key_code_with_modifiers(&raw[offset..], &raw_lower[offset..], modifiers)
+}
+
+/// Returns the byte offset past the modifier prefixes, and the modifiers found.
+fn extract_modifiers(raw_lower: &str) -> (usize, KeyModifiers) {
+ let mut modifiers = KeyModifiers::empty();
+ let mut offset = 0;
+
+ loop {
+ let rest = &raw_lower[offset..];
+ if rest.starts_with("ctrl-") {
+ modifiers.insert(KeyModifiers::CONTROL);
+ offset += 5;
+ } else if rest.starts_with("alt-") {
+ modifiers.insert(KeyModifiers::ALT);
+ offset += 4;
+ } else if rest.starts_with("shift-") {
+ modifiers.insert(KeyModifiers::SHIFT);
+ offset += 6;
+ } else {
+ break;
+ }
+ }
+
+ (offset, modifiers)
+}
+
+fn parse_key_code_with_modifiers(
+ raw: &str,
+ raw_lower: &str,
+ mut modifiers: KeyModifiers,
+) -> color_eyre::Result<KeyEvent, String> {
+ let c = match raw_lower {
+ "esc" => KeyCode::Esc,
+ "enter" => KeyCode::Enter,
+ "left" => KeyCode::Left,
+ "right" => KeyCode::Right,
+ "up" => KeyCode::Up,
+ "down" => KeyCode::Down,
+ "home" => KeyCode::Home,
+ "end" => KeyCode::End,
+ "pageup" => KeyCode::PageUp,
+ "pagedown" => KeyCode::PageDown,
+ "backtab" => {
+ modifiers.insert(KeyModifiers::SHIFT);
+ KeyCode::BackTab
+ }
+ "backspace" => KeyCode::Backspace,
+ "delete" => KeyCode::Delete,
+ "insert" => KeyCode::Insert,
+ "f1" => KeyCode::F(1),
+ "f2" => KeyCode::F(2),
+ "f3" => KeyCode::F(3),
+ "f4" => KeyCode::F(4),
+ "f5" => KeyCode::F(5),
+ "f6" => KeyCode::F(6),
+ "f7" => KeyCode::F(7),
+ "f8" => KeyCode::F(8),
+ "f9" => KeyCode::F(9),
+ "f10" => KeyCode::F(10),
+ "f11" => KeyCode::F(11),
+ "f12" => KeyCode::F(12),
+ "space" => KeyCode::Char(' '),
+ "hyphen" => KeyCode::Char('-'),
+ "minus" => KeyCode::Char('-'),
+ "tab" => KeyCode::Tab,
+ _ if raw.chars().count() == 1 => {
+ // Case comes from the original token, not the lowercased copy.
+ let mut c = raw.chars().next().ok_or("empty key")?;
+ if modifiers.contains(KeyModifiers::SHIFT) {
+ c = c.to_ascii_uppercase();
+ } else if c.is_ascii_uppercase() {
+ // crossterm reports a capital as Char('A') with SHIFT held, so
+ // `<A>` must produce exactly that to ever match.
+ modifiers.insert(KeyModifiers::SHIFT);
+ }
+ KeyCode::Char(c)
+ }
+ _ => return Err(format!("Unable to parse {raw}")),
+ };
+ Ok(KeyEvent::new(c, modifiers))
+}
+
+pub fn key_event_to_string(key_event: &KeyEvent) -> String {
+ let char;
+ let key_code = match key_event.code {
+ KeyCode::Backspace => "backspace",
+ KeyCode::Enter => "enter",
+ KeyCode::Left => "left",
+ KeyCode::Right => "right",
+ KeyCode::Up => "up",
+ KeyCode::Down => "down",
+ KeyCode::Home => "home",
+ KeyCode::End => "end",
+ KeyCode::PageUp => "pageup",
+ KeyCode::PageDown => "pagedown",
+ KeyCode::Tab => "tab",
+ KeyCode::BackTab => "backtab",
+ KeyCode::Delete => "delete",
+ KeyCode::Insert => "insert",
+ KeyCode::F(c) => {
+ char = format!("f({c})");
+ &char
+ }
+ KeyCode::Char(' ') => "space",
+ KeyCode::Char(c) => {
+ char = c.to_string();
+ &char
+ }
+ KeyCode::Esc => "esc",
+ KeyCode::Null => "",
+ KeyCode::CapsLock => "",
+ KeyCode::Menu => "",
+ KeyCode::ScrollLock => "",
+ KeyCode::Media(_) => "",
+ KeyCode::NumLock => "",
+ KeyCode::PrintScreen => "",
+ KeyCode::Pause => "",
+ KeyCode::KeypadBegin => "",
+ KeyCode::Modifier(_) => "",
+ };
+
+ let mut modifiers = Vec::with_capacity(3);
+
+ if key_event.modifiers.intersects(KeyModifiers::CONTROL) {
+ modifiers.push("ctrl");
+ }
+
+ if key_event.modifiers.intersects(KeyModifiers::SHIFT) {
+ modifiers.push("shift");
+ }
+
+ if key_event.modifiers.intersects(KeyModifiers::ALT) {
+ modifiers.push("alt");
+ }
+
+ let mut key = modifiers.join("-");
+
+ if !key.is_empty() {
+ key.push('-');
+ }
+ key.push_str(key_code);
+
+ key
+}
+
+pub fn parse_key_sequence(raw: &str) -> color_eyre::Result<Vec<KeyEvent>, String> {
+ if raw.chars().filter(|c| *c == '>').count() != raw.chars().filter(|c| *c == '<').count() {
+ return Err(format!("Unable to parse `{}`", raw));
+ }
+ let raw = if !raw.contains("><") {
+ let raw = raw.strip_prefix('<').unwrap_or(raw);
+ raw.strip_prefix('>').unwrap_or(raw)
+ } else {
+ raw
+ };
+ let sequences = raw
+ .split("><")
+ .map(|seq| {
+ if let Some(s) = seq.strip_prefix('<') {
+ s
+ } else if let Some(s) = seq.strip_suffix('>') {
+ s
+ } else {
+ seq
+ }
+ })
+ .collect::<Vec<_>>();
+
+ sequences.into_iter().map(parse_key_event).collect()
+}
+
+#[derive(Clone, Debug, Default)]
+pub struct Styles(pub HashMap<Mode, HashMap<String, Style>>);
+
+impl<'de> Deserialize<'de> for Styles {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: Deserializer<'de>,
+ {
+ let parsed_map = HashMap::<Mode, HashMap<String, String>>::deserialize(deserializer)?;
+
+ let styles = parsed_map
+ .into_iter()
+ .map(|(mode, inner_map)| {
+ let converted_inner_map = inner_map
+ .into_iter()
+ .map(|(str, style)| (str, parse_style(&style)))
+ .collect();
+ (mode, converted_inner_map)
+ })
+ .collect();
+
+ Ok(Styles(styles))
+ }
+}
+
+pub fn parse_style(line: &str) -> Style {
+ let (foreground, background) =
+ line.split_at(line.to_lowercase().find("on ").unwrap_or(line.len()));
+ let foreground = process_color_string(foreground);
+ let background = process_color_string(&background.replace("on ", ""));
+
+ let mut style = Style::default();
+ if let Some(fg) = parse_color(&foreground.0) {
+ style = style.fg(fg);
+ }
+ if let Some(bg) = parse_color(&background.0) {
+ style = style.bg(bg);
+ }
+ style = style.add_modifier(foreground.1 | background.1);
+ style
+}
+
+fn process_color_string(color_str: &str) -> (String, Modifier) {
+ let color = color_str
+ .replace("grey", "gray")
+ .replace("bright ", "")
+ .replace("bold ", "")
+ .replace("underline ", "")
+ .replace("inverse ", "");
+
+ let mut modifiers = Modifier::empty();
+ if color_str.contains("underline") {
+ modifiers |= Modifier::UNDERLINED;
+ }
+ if color_str.contains("bold") {
+ modifiers |= Modifier::BOLD;
+ }
+ if color_str.contains("inverse") {
+ modifiers |= Modifier::REVERSED;
+ }
+
+ (color, modifiers)
+}
+
+fn parse_color(s: &str) -> Option<Color> {
+ let s = s.trim_start();
+ let s = s.trim_end();
+ // Every arm below must tolerate arbitrary user input: these strings come
+ // from `[styles]` in config.toml, and a panic here would take down the
+ // editor at startup (MJB-HLR-018). The original template code indexed
+ // `rgb` operands unchecked and added into `u8` unguarded, so `"rgb"`,
+ // `"rgb1"`, `"gray99"` and `"rgb999"` all aborted the process.
+ if s.contains("bright color") {
+ let c = s
+ .trim_start_matches("bright ")
+ .trim_start_matches("color")
+ .parse::<u8>()
+ .unwrap_or_default();
+ // Bright ANSI colours are the base colour plus 8. The template wrote
+ // `wrapping_shl(8)`, which on a `u8` masks the shift to 8 % 8 == 0 and
+ // so returned the colour unchanged.
+ Some(Color::Indexed(c.saturating_add(8)))
+ } else if s.contains("color") {
+ let c = s
+ .trim_start_matches("color")
+ .parse::<u8>()
+ .unwrap_or_default();
+ Some(Color::Indexed(c))
+ } else if s.contains("gray") {
+ // The xterm grayscale ramp is 24 steps at indices 232..=255.
+ let step = s
+ .trim_start_matches("gray")
+ .parse::<u8>()
+ .unwrap_or_default()
+ .min(23);
+ Some(Color::Indexed(232 + step))
+ } else if let Some(digits) = s.strip_prefix("rgb") {
+ // The xterm 216-colour cube at indices 16..=231: three components,
+ // each 0–5. `to_digit(6)` rejects anything outside that range, so the
+ // arithmetic below cannot exceed 231.
+ let mut components = digits.chars().map(|c| c.to_digit(6));
+ match (components.next(), components.next(), components.next()) {
+ (Some(Some(r)), Some(Some(g)), Some(Some(b))) => {
+ let index = 16 + r * 36 + g * 6 + b;
+ debug_assert!(index <= 231);
+ Some(Color::Indexed(index as u8))
+ }
+ _ => None,
+ }
+ } else if s == "bold black" {
+ Some(Color::Indexed(8))
+ } else if s == "bold red" {
+ Some(Color::Indexed(9))
+ } else if s == "bold green" {
+ Some(Color::Indexed(10))
+ } else if s == "bold yellow" {
+ Some(Color::Indexed(11))
+ } else if s == "bold blue" {
+ Some(Color::Indexed(12))
+ } else if s == "bold magenta" {
+ Some(Color::Indexed(13))
+ } else if s == "bold cyan" {
+ Some(Color::Indexed(14))
+ } else if s == "bold white" {
+ Some(Color::Indexed(15))
+ } else if s == "black" {
+ Some(Color::Indexed(0))
+ } else if s == "red" {
+ Some(Color::Indexed(1))
+ } else if s == "green" {
+ Some(Color::Indexed(2))
+ } else if s == "yellow" {
+ Some(Color::Indexed(3))
+ } else if s == "blue" {
+ Some(Color::Indexed(4))
+ } else if s == "magenta" {
+ Some(Color::Indexed(5))
+ } else if s == "cyan" {
+ Some(Color::Indexed(6))
+ } else if s == "white" {
+ Some(Color::Indexed(7))
+ } else {
+ None
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use pretty_assertions::assert_eq;
+
+ use super::*;
+
+ #[test]
+ fn test_parse_style_default() {
+ let style = parse_style("");
+ assert_eq!(style, Style::default());
+ }
+
+ #[test]
+ fn test_parse_style_foreground() {
+ let style = parse_style("red");
+ assert_eq!(style.fg, Some(Color::Indexed(1)));
+ }
+
+ #[test]
+ fn test_parse_style_background() {
+ let style = parse_style("on blue");
+ assert_eq!(style.bg, Some(Color::Indexed(4)));
+ }
+
+ #[test]
+ fn test_parse_style_modifiers() {
+ let style = parse_style("underline red on blue");
+ assert_eq!(style.fg, Some(Color::Indexed(1)));
+ assert_eq!(style.bg, Some(Color::Indexed(4)));
+ }
+
+ #[test]
+ fn test_process_color_string() {
+ let (color, modifiers) = process_color_string("underline bold inverse gray");
+ assert_eq!(color, "gray");
+ assert!(modifiers.contains(Modifier::UNDERLINED));
+ assert!(modifiers.contains(Modifier::BOLD));
+ assert!(modifiers.contains(Modifier::REVERSED));
+ }
+
+ #[test]
+ fn test_parse_color_rgb() {
+ let color = parse_color("rgb123");
+ let expected = 16 + 36 + 2 * 6 + 3;
+ assert_eq!(color, Some(Color::Indexed(expected)));
+ }
+
+ #[test]
+ fn test_parse_color_unknown() {
+ let color = parse_color("unknown");
+ assert_eq!(color, None);
+ }
+
+ /// MJB-HLR-018: colour strings come from user configuration, so no input
+ /// may abort the process. Regression guard — every case below panicked in
+ /// the template code this replaced, by unchecked indexing or by `u8`
+ /// overflow in a debug build.
+ #[test]
+ fn mjb_llr_183_malformed_colours_never_panic() {
+ for input in [
+ "rgb", // indexed byte 3 of a 3-byte string
+ "rgb1", // indexed bytes 4 and 5
+ "rgb12", // indexed byte 5
+ "rgb999", // 16 + 9*36 + 9*6 + 9 = 403, overflows u8
+ "rgb555", // the largest legal cube entry
+ "gray99", // 232 + 99 = 331, overflows u8
+ "gray", // no digits at all
+ "color999", // does not fit u8
+ "bright color999",
+ "",
+ "文字化け", // multi-byte: byte indexing would split a character
+ ] {
+ let _ = parse_color(input);
+ let _ = parse_style(input);
+ }
+ }
+
+ #[test]
+ fn mjb_llr_183_colour_cube_bounds() {
+ // 216-colour cube occupies 16..=231.
+ assert_eq!(parse_color("rgb000"), Some(Color::Indexed(16)));
+ assert_eq!(parse_color("rgb555"), Some(Color::Indexed(231)));
+ // Components outside 0–5 are not cube coordinates.
+ assert_eq!(parse_color("rgb600"), None);
+ }
+
+ #[test]
+ fn mjb_llr_183_grayscale_ramp_bounds() {
+ // Grayscale ramp occupies 232..=255.
+ assert_eq!(parse_color("gray0"), Some(Color::Indexed(232)));
+ assert_eq!(parse_color("gray23"), Some(Color::Indexed(255)));
+ assert_eq!(
+ parse_color("gray99"),
+ Some(Color::Indexed(255)),
+ "clamped to the end of the ramp rather than overflowing"
+ );
+ }
+
+ #[test]
+ fn mjb_llr_183_bright_colour_is_base_plus_eight() {
+ // The template's `wrapping_shl(8)` masked to a zero-bit shift, so
+ // bright colours were indistinguishable from their base.
+ assert_eq!(parse_color("bright color1"), Some(Color::Indexed(9)));
+ assert_ne!(parse_color("bright color1"), parse_color("color1"));
+ assert_eq!(
+ parse_color("bright color255"),
+ Some(Color::Indexed(255)),
+ "saturates instead of wrapping"
+ );
+ }
+
+ /// MJB-LLR-180: the compiled-in default TOML parses, and carries the
+ /// bindings the requirements mandate.
+ #[test]
+ fn mjb_llr_180_builtin_defaults_parse() {
+ let c: Config = toml::from_str(CONFIG).expect("built-in config.toml must parse");
+
+ let normal = c.keybindings.0.get(&Mode::Normal).expect("normal bindings");
+ assert_eq!(
+ normal.get(&parse_key_sequence("<h>").unwrap()),
+ Some(&Command::MoveCharLeft)
+ );
+ // A two-key sequence must survive parsing as two events.
+ let gg = parse_key_sequence("<g><g>").unwrap();
+ assert_eq!(gg.len(), 2);
+ assert_eq!(normal.get(&gg), Some(&Command::GotoFileStart));
+
+ let global = c.keybindings.0.get(&Mode::Global).expect("global bindings");
+ assert_eq!(
+ global.get(&parse_key_sequence("<ctrl-c>").unwrap()),
+ Some(&Command::Quit)
+ );
+ }
+
+ /// MJB-LLR-181: `config.toml` is the only file consulted. A config in any
+ /// other format sitting in the same directory must be ignored entirely.
+ #[test]
+ fn mjb_llr_181_only_config_toml_is_read() {
+ let dir = tempfile::tempdir().unwrap();
+
+ // Decoys in the formats the template used to accept.
+ std::fs::write(
+ dir.path().join("config.json5"),
+ "{ \"keybindings\": { \"normal\": { \"<q>\": \"Quit\" } } }",
+ )
+ .unwrap();
+ std::fs::write(dir.path().join("config.json"), "{\"editor\":{\"scrolloff\":99}}").unwrap();
+ std::fs::write(dir.path().join("config.yaml"), "editor:\n scrolloff: 98\n").unwrap();
+ std::fs::write(dir.path().join("config.ini"), "[editor]\nscrolloff=97\n").unwrap();
+ std::fs::write(dir.path().join("config.toml"), "[editor]\nscrolloff = 7\n").unwrap();
+
+ let cfg: Config = config::Config::builder()
+ .add_source(
+ config::File::from(dir.path().join("config.toml"))
+ .format(config::FileFormat::Toml)
+ .required(false),
+ )
+ .build()
+ .unwrap()
+ .try_deserialize()
+ .unwrap();
+
+ assert_eq!(cfg.editor.scrolloff, 7, "the TOML file must win");
+ }
+
+ /// MJB-LLR-185: editor settings deserialize with the documented defaults.
+ #[test]
+ fn mjb_llr_185_editor_defaults() {
+ let c: Config = toml::from_str(CONFIG).unwrap();
+ assert_eq!(c.editor.scrolloff, 5);
+ assert!(c.editor.insert_final_newline);
+
+ let empty: Config = toml::from_str("").unwrap();
+ assert_eq!(empty.editor.scrolloff, 5);
+ assert!(empty.editor.insert_final_newline);
+ }
+
+ /// MJB-LLR-182: a user binding overrides one default without disturbing
+ /// the rest of that mode.
+ #[test]
+ fn mjb_llr_182_user_bindings_merge_per_binding() {
+ let mut defaults: Config = toml::from_str(CONFIG).unwrap();
+ let user: Config =
+ toml::from_str("[keybindings.normal]\n\"<h>\" = \"MoveCharRight\"\n").unwrap();
+
+ // Same merge direction as Config::new: user wins, defaults fill in.
+ let mut merged = user;
+ for (mode, default_bindings) in defaults.keybindings.0.drain() {
+ let entry = merged.keybindings.0.entry(mode).or_default();
+ for (key, cmd) in default_bindings {
+ entry.entry(key).or_insert(cmd);
+ }
+ }
+
+ let normal = merged.keybindings.0.get(&Mode::Normal).unwrap();
+ assert_eq!(
+ normal.get(&parse_key_sequence("<h>").unwrap()),
+ Some(&Command::MoveCharRight),
+ "user binding must win"
+ );
+ assert_eq!(
+ normal.get(&parse_key_sequence("<j>").unwrap()),
+ Some(&Command::MoveLineDown),
+ "untouched defaults must remain"
+ );
+ }
+
+ /// MJB-LLR-183: a malformed key sequence is a recoverable error naming the
+ /// offending string, not a panic.
+ #[test]
+ fn mjb_llr_183_invalid_keybinding_is_recoverable() {
+ let err = toml::from_str::<Config>("[keybindings.normal]\n\"<nonsense-key>\" = \"Undo\"\n")
+ .expect_err("must not deserialize");
+ assert!(
+ err.to_string().contains("nonsense-key"),
+ "error must name the offending key, got: {err}"
+ );
+ }
+
+ /// MJB-LLR-184: modes deserialize from their lower-case names.
+ #[test]
+ fn mjb_llr_184_modes_deserialize_lowercase() {
+ let c: Config = toml::from_str(
+ "[keybindings.normal]\n\"<a>\" = \"Undo\"\n\
+ [keybindings.insert]\n\"<b>\" = \"Undo\"\n\
+ [keybindings.select]\n\"<c>\" = \"Undo\"\n\
+ [keybindings.command]\n\"<d>\" = \"Undo\"\n\
+ [keybindings.global]\n\"<e>\" = \"Undo\"\n",
+ )
+ .unwrap();
+ for mode in [
+ Mode::Normal,
+ Mode::Insert,
+ Mode::Select,
+ Mode::Command,
+ Mode::Global,
+ ] {
+ assert!(c.keybindings.0.contains_key(&mode), "missing {mode:?}");
+ }
+ }
+
+ #[test]
+ fn test_simple_keys() {
+ assert_eq!(
+ parse_key_event("a").unwrap(),
+ KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty())
+ );
+
+ assert_eq!(
+ parse_key_event("enter").unwrap(),
+ KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())
+ );
+
+ assert_eq!(
+ parse_key_event("esc").unwrap(),
+ KeyEvent::new(KeyCode::Esc, KeyModifiers::empty())
+ );
+ }
+
+ #[test]
+ fn test_with_modifiers() {
+ assert_eq!(
+ parse_key_event("ctrl-a").unwrap(),
+ KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)
+ );
+
+ assert_eq!(
+ parse_key_event("alt-enter").unwrap(),
+ KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT)
+ );
+
+ assert_eq!(
+ parse_key_event("shift-esc").unwrap(),
+ KeyEvent::new(KeyCode::Esc, KeyModifiers::SHIFT)
+ );
+ }
+
+ #[test]
+ fn test_multiple_modifiers() {
+ assert_eq!(
+ parse_key_event("ctrl-alt-a").unwrap(),
+ KeyEvent::new(
+ KeyCode::Char('a'),
+ KeyModifiers::CONTROL | KeyModifiers::ALT
+ )
+ );
+
+ assert_eq!(
+ parse_key_event("ctrl-shift-enter").unwrap(),
+ KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL | KeyModifiers::SHIFT)
+ );
+ }
+
+ #[test]
+ fn test_reverse_multiple_modifiers() {
+ assert_eq!(
+ key_event_to_string(&KeyEvent::new(
+ KeyCode::Char('a'),
+ KeyModifiers::CONTROL | KeyModifiers::ALT
+ )),
+ "ctrl-alt-a".to_string()
+ );
+ }
+
+ #[test]
+ fn test_invalid_keys() {
+ assert!(parse_key_event("invalid-key").is_err());
+ assert!(parse_key_event("ctrl-invalid-key").is_err());
+ }
+
+ #[test]
+ fn test_case_insensitivity() {
+ assert_eq!(
+ parse_key_event("CTRL-a").unwrap(),
+ KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)
+ );
+
+ assert_eq!(
+ parse_key_event("AlT-eNtEr").unwrap(),
+ KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT)
+ );
+ }
+}
diff --git a/src/errors.rs b/src/errors.rs
new file mode 100644
index 0000000..ebed8af
--- /dev/null
+++ b/src/errors.rs
@@ -0,0 +1,77 @@
+use std::env;
+
+use tracing::error;
+
+pub fn init() -> color_eyre::Result<()> {
+ let (panic_hook, eyre_hook) = color_eyre::config::HookBuilder::default()
+ .panic_section(format!(
+ "This is a bug. Consider reporting it at {}",
+ env!("CARGO_PKG_REPOSITORY")
+ ))
+ .capture_span_trace_by_default(false)
+ .display_location_section(false)
+ .display_env_section(false)
+ .into_hooks();
+ eyre_hook.install()?;
+ std::panic::set_hook(Box::new(move |panic_info| {
+ if let Ok(mut t) = crate::tui::Tui::new()
+ && let Err(r) = t.exit()
+ {
+ error!("Unable to exit Terminal: {:?}", r);
+ }
+
+ #[cfg(not(debug_assertions))]
+ {
+ use human_panic::{handle_dump, metadata, print_msg};
+ let metadata = metadata!();
+ let file_path = handle_dump(&metadata, panic_info);
+ // prints human-panic message
+ print_msg(file_path, &metadata)
+ .expect("human-panic: printing error message to console failed");
+ eprintln!("{}", panic_hook.panic_report(panic_info)); // prints color-eyre stack trace to stderr
+ }
+ let msg = format!("{}", panic_hook.panic_report(panic_info));
+ error!("Error: {}", strip_ansi_escapes::strip_str(msg));
+
+ #[cfg(debug_assertions)]
+ {
+ // Better Panic stacktrace that is only enabled when debugging.
+ better_panic::Settings::auto()
+ .most_recent_first(false)
+ .lineno_suffix(true)
+ .verbosity(better_panic::Verbosity::Full)
+ .create_panic_handler()(panic_info);
+ }
+
+ std::process::exit(libc::EXIT_FAILURE);
+ }));
+ Ok(())
+}
+
+/// Similar to the `std::dbg!` macro, but generates `tracing` events rather
+/// than printing to stdout.
+///
+/// By default, the verbosity level for the generated events is `DEBUG`, but
+/// this can be customized.
+#[macro_export]
+macro_rules! trace_dbg {
+ (target: $target:expr, level: $level:expr, $ex:expr) => {
+ {
+ match $ex {
+ value => {
+ tracing::event!(target: $target, $level, ?value, stringify!($ex));
+ value
+ }
+ }
+ }
+ };
+ (level: $level:expr, $ex:expr) => {
+ trace_dbg!(target: module_path!(), level: $level, $ex)
+ };
+ (target: $target:expr, $ex:expr) => {
+ trace_dbg!(target: $target, level: tracing::Level::DEBUG, $ex)
+ };
+ ($ex:expr) => {
+ trace_dbg!(level: tracing::Level::DEBUG, $ex)
+ };
+}
diff --git a/src/lib.rs b/src/lib.rs
new file mode 100644
index 0000000..da70baa
--- /dev/null
+++ b/src/lib.rs
@@ -0,0 +1,18 @@
+//! 文字化け — a terminal text editor.
+//!
+//! The crate is split into a library and a thin binary so that the buffer core
+//! under [`buffer`] can be exercised by requirements-based tests without a
+//! terminal, and so structural coverage can be scoped to it.
+//!
+//! Developed to DO-178C DAL-C. Requirements live in `docs/requirements/`;
+//! items implementing a low-level requirement carry a `MJB-LLR-nnn` comment.
+
+pub mod action;
+pub mod app;
+pub mod buffer;
+pub mod cli;
+pub mod components;
+pub mod config;
+pub mod errors;
+pub mod logging;
+pub mod tui;
diff --git a/src/logging.rs b/src/logging.rs
new file mode 100644
index 0000000..fd4e0b6
--- /dev/null
+++ b/src/logging.rs
@@ -0,0 +1,36 @@
+use std::sync::LazyLock;
+
+use tracing_error::ErrorLayer;
+use tracing_subscriber::{EnvFilter, fmt, prelude::*};
+
+use crate::config;
+
+pub static LOG_ENV: LazyLock<String> =
+ LazyLock::new(|| format!("{}_LOG_LEVEL", config::PROJECT_NAME.clone()));
+pub static LOG_FILE: LazyLock<String> = LazyLock::new(|| format!("{}.log", config::APP_NAME));
+
+pub fn init() -> color_eyre::Result<()> {
+ let directory = config::get_data_dir();
+ std::fs::create_dir_all(directory.clone())?;
+ let log_path = directory.join(LOG_FILE.clone());
+ let log_file = std::fs::File::create(log_path)?;
+ let env_filter = EnvFilter::builder().with_default_directive(tracing::Level::INFO.into());
+ // If the `RUST_LOG` environment variable is set, use that as the default, otherwise use the
+ // value of the `LOG_ENV` environment variable. If the `LOG_ENV` environment variable contains
+ // errors, then this will return an error.
+ let env_filter = env_filter
+ .try_from_env()
+ .or_else(|_| env_filter.with_env_var(LOG_ENV.clone()).from_env())?;
+ let file_subscriber = fmt::layer()
+ .with_file(true)
+ .with_line_number(true)
+ .with_writer(log_file)
+ .with_target(false)
+ .with_ansi(false)
+ .with_filter(env_filter);
+ tracing_subscriber::registry()
+ .with(file_subscriber)
+ .with(ErrorLayer::default())
+ .try_init()?;
+ Ok(())
+}
diff --git a/src/main.rs b/src/main.rs
index e7a11a9..b87ff47 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,3 +1,14 @@
-fn main() {
- println!("Hello, world!");
+use clap::Parser;
+use mojibake::{app::App, cli::Cli, errors, logging};
+
+#[tokio::main]
+async fn main() -> color_eyre::Result<()> {
+ errors::init()?;
+ logging::init()?;
+
+ let args = Cli::parse();
+ // MJB-LLR-111: the optional positional path is handed to the buffer.
+ let mut app = App::new(args.tick_rate, args.frame_rate, args.file)?;
+ app.run().await?;
+ Ok(())
}
diff --git a/src/tui.rs b/src/tui.rs
new file mode 100644
index 0000000..8188985
--- /dev/null
+++ b/src/tui.rs
@@ -0,0 +1,233 @@
+
+use std::{
+ io::{Stdout, stdout},
+ ops::{Deref, DerefMut},
+ time::Duration,
+};
+
+use crossterm::{
+ cursor,
+ event::{
+ DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
+ Event as CrosstermEvent, EventStream, KeyEvent, KeyEventKind, MouseEvent,
+ },
+ terminal::{EnterAlternateScreen, LeaveAlternateScreen},
+};
+use futures::{FutureExt, StreamExt};
+use ratatui::backend::CrosstermBackend as Backend;
+use serde::{Deserialize, Serialize};
+use tokio::{
+ sync::mpsc::{self, UnboundedReceiver, UnboundedSender},
+ task::JoinHandle,
+ time::interval,
+};
+use tokio_util::sync::CancellationToken;
+use tracing::error;
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub enum Event {
+ Init,
+ Quit,
+ Error,
+ Closed,
+ Tick,
+ Render,
+ FocusGained,
+ FocusLost,
+ Paste(String),
+ Key(KeyEvent),
+ Mouse(MouseEvent),
+ Resize(u16, u16),
+}
+
+pub struct Tui {
+ pub terminal: ratatui::Terminal<Backend<Stdout>>,
+ pub task: JoinHandle<()>,
+ pub cancellation_token: CancellationToken,
+ pub event_rx: UnboundedReceiver<Event>,
+ pub event_tx: UnboundedSender<Event>,
+ pub frame_rate: f64,
+ pub tick_rate: f64,
+ pub mouse: bool,
+ pub paste: bool,
+}
+
+impl Tui {
+ pub fn new() -> color_eyre::Result<Self> {
+ let (event_tx, event_rx) = mpsc::unbounded_channel();
+ Ok(Self {
+ terminal: ratatui::Terminal::new(Backend::new(stdout()))?,
+ task: tokio::spawn(async {}),
+ cancellation_token: CancellationToken::new(),
+ event_rx,
+ event_tx,
+ frame_rate: 60.0,
+ tick_rate: 4.0,
+ mouse: false,
+ paste: false,
+ })
+ }
+
+ pub fn tick_rate(mut self, tick_rate: f64) -> Self {
+ self.tick_rate = tick_rate;
+ self
+ }
+
+ pub fn frame_rate(mut self, frame_rate: f64) -> Self {
+ self.frame_rate = frame_rate;
+ self
+ }
+
+ pub fn mouse(mut self, mouse: bool) -> Self {
+ self.mouse = mouse;
+ self
+ }
+
+ pub fn paste(mut self, paste: bool) -> Self {
+ self.paste = paste;
+ self
+ }
+
+ pub fn start(&mut self) {
+ self.cancel(); // Cancel any existing task
+ self.cancellation_token = CancellationToken::new();
+ let event_loop = Self::event_loop(
+ self.event_tx.clone(),
+ self.cancellation_token.clone(),
+ self.tick_rate,
+ self.frame_rate,
+ );
+ self.task = tokio::spawn(async {
+ event_loop.await;
+ });
+ }
+
+ async fn event_loop(
+ event_tx: UnboundedSender<Event>,
+ cancellation_token: CancellationToken,
+ tick_rate: f64,
+ frame_rate: f64,
+ ) {
+ let mut event_stream = EventStream::new();
+ let mut tick_interval = interval(Duration::from_secs_f64(1.0 / tick_rate));
+ let mut render_interval = interval(Duration::from_secs_f64(1.0 / frame_rate));
+
+ // if this fails, then it's likely a bug in the calling code
+ event_tx
+ .send(Event::Init)
+ .expect("failed to send init event");
+ loop {
+ let event = tokio::select! {
+ _ = cancellation_token.cancelled() => {
+ break;
+ }
+ _ = tick_interval.tick() => Event::Tick,
+ _ = render_interval.tick() => Event::Render,
+ crossterm_event = event_stream.next().fuse() => match crossterm_event {
+ Some(Ok(event)) => match event {
+ CrosstermEvent::Key(key) if key.kind == KeyEventKind::Press => Event::Key(key),
+ CrosstermEvent::Mouse(mouse) => Event::Mouse(mouse),
+ CrosstermEvent::Resize(x, y) => Event::Resize(x, y),
+ CrosstermEvent::FocusLost => Event::FocusLost,
+ CrosstermEvent::FocusGained => Event::FocusGained,
+ CrosstermEvent::Paste(s) => Event::Paste(s),
+ _ => continue, // ignore other events
+ }
+ Some(Err(_)) => Event::Error,
+ None => break, // the event stream has stopped and will not produce any more events
+ },
+ };
+ if event_tx.send(event).is_err() {
+ // the receiver has been dropped, so there's no point in continuing the loop
+ break;
+ }
+ }
+ cancellation_token.cancel();
+ }
+
+ pub fn stop(&self) -> color_eyre::Result<()> {
+ self.cancel();
+ let mut counter = 0;
+ while !self.task.is_finished() {
+ std::thread::sleep(Duration::from_millis(1));
+ counter += 1;
+ if counter > 50 {
+ self.task.abort();
+ }
+ if counter > 100 {
+ error!("Failed to abort task in 100 milliseconds for unknown reason");
+ break;
+ }
+ }
+ Ok(())
+ }
+
+ pub fn enter(&mut self) -> color_eyre::Result<()> {
+ crossterm::terminal::enable_raw_mode()?;
+ crossterm::execute!(stdout(), EnterAlternateScreen, cursor::Hide)?;
+ if self.mouse {
+ crossterm::execute!(stdout(), EnableMouseCapture)?;
+ }
+ if self.paste {
+ crossterm::execute!(stdout(), EnableBracketedPaste)?;
+ }
+ self.start();
+ Ok(())
+ }
+
+ pub fn exit(&mut self) -> color_eyre::Result<()> {
+ self.stop()?;
+ if crossterm::terminal::is_raw_mode_enabled()? {
+ self.flush()?;
+ if self.paste {
+ crossterm::execute!(stdout(), DisableBracketedPaste)?;
+ }
+ if self.mouse {
+ crossterm::execute!(stdout(), DisableMouseCapture)?;
+ }
+ crossterm::execute!(stdout(), LeaveAlternateScreen, cursor::Show)?;
+ crossterm::terminal::disable_raw_mode()?;
+ }
+ Ok(())
+ }
+
+ pub fn cancel(&self) {
+ self.cancellation_token.cancel();
+ }
+
+ pub fn suspend(&mut self) -> color_eyre::Result<()> {
+ self.exit()?;
+ #[cfg(not(windows))]
+ signal_hook::low_level::raise(signal_hook::consts::signal::SIGTSTP)?;
+ Ok(())
+ }
+
+ pub fn resume(&mut self) -> color_eyre::Result<()> {
+ self.enter()?;
+ Ok(())
+ }
+
+ pub async fn next_event(&mut self) -> Option<Event> {
+ self.event_rx.recv().await
+ }
+}
+
+impl Deref for Tui {
+ type Target = ratatui::Terminal<Backend<Stdout>>;
+
+ fn deref(&self) -> &Self::Target {
+ &self.terminal
+ }
+}
+
+impl DerefMut for Tui {
+ fn deref_mut(&mut self) -> &mut Self::Target {
+ &mut self.terminal
+ }
+}
+
+impl Drop for Tui {
+ fn drop(&mut self) {
+ self.exit().unwrap();
+ }
+}