From b746d6e3f9b3e56386210eda653407b014322801 Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Tue, 7 Jul 2026 23:44:46 +0200 Subject: [PATCH] feat(editor): add Markdown affordances and a :fmt formatter v0.6 affordances plus the v0.4 command-line mechanism, all in editor.rs: - soft-wrap display lines at word boundaries (layout() no longer cuts mid-word; a single word wider than the panel still hard-breaks) - Enter continues Markdown lists: -/*/+ repeat, N. increments, an empty item exits the list; only at end-of-line, indentation preserved - heading lines (# .. ######) render faux-bold via a 1px double-strike (no bold Latin-9 font); checks the logical line so wrapped headings stay bold - new `:` command mode (Command variant + status-strip echo, Esc/Backspace cancel) with one command, `:fmt`: align pipe tables (honoring :--: colons), collapse runs of blank lines to one, strip trailing whitespace; caret lands on roughly the same line afterwards --- firmware/src/editor.rs | 387 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 357 insertions(+), 30 deletions(-) diff --git a/firmware/src/editor.rs b/firmware/src/editor.rs index 1af89e0..92ab1b8 100644 --- a/firmware/src/editor.rs +++ b/firmware/src/editor.rs @@ -37,6 +37,9 @@ pub enum Mode { Insert, /// Read-only reading: keys scroll the viewport, edits are locked out. View, + /// `:` command line — keys accumulate a command shown in the status strip; + /// Enter runs it, Esc cancels. Currently just `:fmt`. + Command, } /// A pending operator awaiting a motion or text object (`d`elete / `c`hange). @@ -64,6 +67,8 @@ pub struct Editor { pending_obj: Option, /// First `g` of a `gg` awaiting the second. pending_g: bool, + /// The `:` command line being typed (valid only in `Mode::Command`). + cmdline: String, } /// One wrapped display line: its text and the buffer offset of its first char. @@ -83,6 +88,7 @@ impl Editor { pending_op: None, pending_obj: None, pending_g: false, + cmdline: String::new(), } } @@ -100,6 +106,7 @@ impl Editor { Mode::Insert => self.insert_key(key), Mode::Normal => self.normal_key(key), Mode::View => self.view_key(key), + Mode::Command => self.command_key(key), } } @@ -109,7 +116,7 @@ impl Editor { match key { Key::Char('\t') => self.insert_str(TAB), Key::Char(c) => self.insert_char(c), - Key::Enter => self.insert_char('\n'), + Key::Enter => self.insert_newline(), Key::Backspace => self.backspace(), Key::DeleteWord => self.delete_word_before(), Key::DeleteLine => self.delete_to_line_start(), @@ -255,11 +262,77 @@ impl Editor { self.mode = Mode::Insert; } 'v' | 'V' => self.mode = Mode::View, + ':' => { + self.reset_pending(); + self.cmdline.clear(); + self.mode = Mode::Command; + return; + } _ => {} } self.count = 0; } + // --- Command mode (`:`) ------------------------------------------------ + + fn command_key(&mut self, key: Key) { + match key { + Key::Char(c) => self.cmdline.push(c), + Key::Backspace => { + // Backspace on the empty command line cancels back to Normal. + if self.cmdline.pop().is_none() { + self.mode = Mode::Normal; + } + } + Key::Enter => { + self.execute_command(); + self.cmdline.clear(); + self.mode = Mode::Normal; + } + Key::Escape => { + self.cmdline.clear(); + self.mode = Mode::Normal; + } + // Word/line deletes and Tab aren't meaningful on a short command line. + _ => {} + } + } + + /// Run the typed `:` command. Unknown commands are silently ignored. + fn execute_command(&mut self) { + match self.cmdline.trim() { + "fmt" => self.format_buffer(), + _ => {} + } + } + + /// `:fmt` — normalize the buffer (align tables, collapse duplicate blank + /// lines, strip trailing whitespace) and keep the caret on roughly the same + /// line (buffer length changes, so exact restoration isn't possible). + fn format_buffer(&mut self) { + let row = self.text[..self.caret].bytes().filter(|&b| b == b'\n').count(); + self.text = format_markdown(&self.text); + // Land the caret at the start of the same logical line, clamped. + let total = self.text.bytes().filter(|&b| b == b'\n').count() + 1; + let target = row.min(total - 1); + self.caret = if target == 0 { + 0 + } else { + let mut seen = 0; + let mut off = self.text.len(); + for (i, b) in self.text.bytes().enumerate() { + if b == b'\n' { + seen += 1; + if seen == target { + off = i + 1; + break; + } + } + } + off + }; + } + fn reset_pending(&mut self) { self.count = 0; self.pending_op = None; @@ -413,6 +486,29 @@ impl Editor { self.caret += s.len(); } + /// Enter in Insert mode, with Markdown list continuation. At the END of a + /// list line (`- `/`* `/`+ ` or `N. `), start the next item automatically — + /// same bullet, or the next number — preserving indentation. Enter on an + /// otherwise-empty item strips the marker instead (exits the list). Anywhere + /// else (mid-line, or a non-list line) it's a plain newline. + fn insert_newline(&mut self) { + let le = self.line_end(self.caret); + if self.caret == le { + let ls = self.line_start(self.caret); + if let Some((next, cur_len, content_empty)) = list_marker(&self.text[ls..le]) { + if content_empty { + // Empty item: drop the marker, leaving a blank line. + self.text.replace_range(ls..ls + cur_len, ""); + self.caret = ls; + } else { + self.insert_str(&format!("\n{next}")); + } + return; + } + } + self.insert_char('\n'); + } + fn backspace(&mut self) { if self.caret > 0 { self.caret -= 1; @@ -628,29 +724,50 @@ impl Editor { // --- Rendering --------------------------------------------------------- /// Wrap the buffer into display lines, tracking each line's buffer offset. + /// Soft-wrap at word boundaries: a logical line too long for `COLS` breaks at + /// the last space that fits, so words are never split — except a single word + /// wider than the panel, which hard-breaks at `COLS` as a fallback. Buffer is + /// ASCII (1 byte = 1 char), so a char index within a line is also a byte + /// offset (matches the rest of `editor.rs`; UTF-8 correctness is v0.2 work). fn layout(&self) -> Vec { - let mut lines = vec![Line { - start: 0, - text: String::new(), - }]; - let mut idx = 0usize; - for ch in self.text.chars() { - if ch == '\n' { - idx += 1; - lines.push(Line { - start: idx, - text: String::new(), - }); - continue; + let mut lines: Vec = Vec::new(); + let mut base = 0usize; // buffer offset of the current logical line's start + for logical in self.text.split('\n') { + let chars: Vec = logical.chars().collect(); + if chars.is_empty() { + lines.push(Line { start: base, text: String::new() }); + } else { + let mut c = 0usize; // char index within `logical` + while c < chars.len() { + let remaining = chars.len() - c; + let take = if remaining <= COLS { + remaining + } else { + // Break at the last space within the COLS-wide window; + // include that space on this line. No space → hard break. + let window = c + COLS; + let mut brk = None; + let mut p = window; + while p > c { + p -= 1; + if chars[p] == ' ' { + brk = Some(p); + break; + } + } + match brk { + Some(sp) if sp > c => sp + 1 - c, + _ => COLS, + } + }; + lines.push(Line { + start: base + c, + text: chars[c..c + take].iter().collect(), + }); + c += take; + } } - if lines.last().unwrap().text.chars().count() >= COLS { - lines.push(Line { - start: idx, - text: String::new(), - }); - } - lines.last_mut().unwrap().text.push(ch); - idx += 1; + base += chars.len() + 1; // + the '\n' that `split` consumed } lines } @@ -688,6 +805,18 @@ impl Editor { } } + /// Is the logical line starting at `ls` a Markdown ATX heading — 1–6 `#` + /// followed by a space? (Used to render heading lines bold.) + fn is_heading_at(&self, ls: usize) -> bool { + let b = self.text.as_bytes(); + let mut i = ls; + while i < b.len() && b[i] == b'#' { + i += 1; + } + let hashes = i - ls; + (1..=6).contains(&hashes) && b.get(i) == Some(&b' ') + } + /// Render the current state into a frame. `insert_cursor_on` gates the /// Insert-mode bar caret (suppressed while typing, shown after a pause); /// Normal draws a block caret and View draws none, regardless. @@ -700,14 +829,18 @@ impl Editor { let text_style = MonoTextStyle::new(&FONT_10X20, BinaryColor::On); let end = (self.scroll_top + ROWS).min(lay.len()); for (vis, li) in (self.scroll_top..end).enumerate() { - Text::with_baseline( - &lay[li].text, - Point::new(0, vis as i32 * CH), - text_style, - Baseline::Top, - ) - .draw(&mut f) - .unwrap(); + let y = vis as i32 * CH; + Text::with_baseline(&lay[li].text, Point::new(0, y), text_style, Baseline::Top) + .draw(&mut f) + .unwrap(); + // Markdown heading (`#`..`######` + space): faux-bold by double- + // striking the whole display line 1px to the right (no bold Latin-9 + // font exists). Checks the logical line so wrapped headings stay bold. + if self.is_heading_at(self.line_start(lay[li].start)) { + Text::with_baseline(&lay[li].text, Point::new(1, y), text_style, Baseline::Top) + .draw(&mut f) + .unwrap(); + } } if crow >= self.scroll_top && crow < self.scroll_top + ROWS { @@ -751,10 +884,21 @@ impl Editor { /// Draw the mode indicator (and any pending count/operator) in the bottom /// strip, in the small 6×10 font so it fits below the 13 text rows. fn draw_status(&self, f: &mut Frame) { + // Command mode shows the command line itself, vim-style, instead of a + // mode name. + if self.mode == Mode::Command { + let s = format!(":{}", self.cmdline); + let style = MonoTextStyle::new(&FONT_6X10, BinaryColor::On); + Text::with_baseline(&s, Point::new(2, ROWS as i32 * CH + 1), style, Baseline::Top) + .draw(f) + .unwrap(); + return; + } let name = match self.mode { Mode::Normal => "NORMAL", Mode::Insert => "INSERT", Mode::View => "VIEW", + Mode::Command => unreachable!(), }; let mut s = format!(" -- {name} --"); if self.count > 0 { @@ -779,3 +923,186 @@ impl Editor { .unwrap(); } } + +/// Parse a Markdown list marker at the start of `line`. Returns +/// `(next_marker, current_marker_len, content_empty)` where `next_marker` is what +/// the following item should start with (same bullet, or the incremented number, +/// preserving indentation), `current_marker_len` is the byte length of this +/// line's marker prefix, and `content_empty` is whether anything follows it. +/// Returns `None` when the line isn't a list item. ASCII throughout (leading +/// spaces, bullets, digits, `. ` are all single-byte). +fn list_marker(line: &str) -> Option<(String, usize, bool)> { + let indent = line.len() - line.trim_start_matches(' ').len(); + let rest = &line[indent..]; + for bullet in ["- ", "* ", "+ "] { + if rest.starts_with(bullet) { + let cur_len = indent + bullet.len(); + let content_empty = line[cur_len..].trim().is_empty(); + return Some((format!("{}{bullet}", &line[..indent]), cur_len, content_empty)); + } + } + // Ordered: `. ` → continue as the next number. + let digits = rest.chars().take_while(|c| c.is_ascii_digit()).count(); + if digits > 0 && rest[digits..].starts_with(". ") { + let cur_len = indent + digits + 2; + let content_empty = line[cur_len..].trim().is_empty(); + let n: usize = rest[..digits].parse().unwrap_or(0); + return Some((format!("{}{}. ", &line[..indent], n + 1), cur_len, content_empty)); + } + None +} + +// --- `:fmt` Markdown normalizer ---------------------------------------------- + +/// Column alignment parsed from a table's `|:--:|` separator row. +#[derive(Clone, Copy)] +enum Align { + Left, + Right, + Center, + None, +} + +/// Normalize a Markdown buffer for `:fmt`: strip trailing whitespace, align +/// pipe tables, and collapse runs of blank lines to a single blank (dropping +/// trailing blanks). Deliberately does NOT reflow paragraphs — the buffer's +/// logical line breaks are the writer's, and display wrapping is soft (see +/// `layout`). ASCII throughout (widths are char counts). +fn format_markdown(text: &str) -> String { + // 1. Trailing-whitespace strip, per line. + let stripped: Vec = text.split('\n').map(|l| l.trim_end().to_string()).collect(); + + // 2. Reformat pipe-table blocks in place; pass everything else through. + let mut piped: Vec = Vec::with_capacity(stripped.len()); + let mut i = 0; + while i < stripped.len() { + if let Some(len) = table_block_len(&stripped[i..]) { + piped.extend(format_table(&stripped[i..i + len])); + i += len; + } else { + piped.push(stripped[i].clone()); + i += 1; + } + } + + // 3. Collapse 2+ consecutive blank lines to one; drop trailing blanks. + let mut out: Vec = Vec::with_capacity(piped.len()); + let mut blank_run = 0; + for line in piped { + if line.is_empty() { + blank_run += 1; + if blank_run == 1 { + out.push(String::new()); + } + } else { + blank_run = 0; + out.push(line); + } + } + while out.last().is_some_and(|l| l.is_empty()) { + out.pop(); + } + out.join("\n") +} + +/// Split a table row into trimmed cells, dropping the empty cells that leading / +/// trailing `|` produce (`| a | b |` → `["a", "b"]`). +fn table_cells(line: &str) -> Vec { + let t = line.trim(); + let t = t.strip_prefix('|').unwrap_or(t); + let t = t.strip_suffix('|').unwrap_or(t); + t.split('|').map(|c| c.trim().to_string()).collect() +} + +/// A separator row: every cell is dashes with optional edge colons (`:--`, `-:`, +/// `:-:`, `---`) and at least one dash. +fn is_separator_row(line: &str) -> bool { + if !line.contains('|') { + return false; + } + let cells = table_cells(line); + !cells.is_empty() + && cells.iter().all(|c| { + !c.is_empty() && c.contains('-') && c.chars().all(|ch| ch == '-' || ch == ':') + }) +} + +/// If `lines[0..]` starts a pipe table (header row + separator row + data rows), +/// return its length in lines; else `None`. +fn table_block_len(lines: &[String]) -> Option { + if lines.len() < 2 || !lines[0].contains('|') || !is_separator_row(&lines[1]) { + return None; + } + let mut n = 2; + while n < lines.len() && !lines[n].is_empty() && lines[n].contains('|') { + n += 1; + } + Some(n) +} + +/// Reformat one detected table block: pad every cell to its column's width and +/// rebuild the separator row, honoring per-column alignment colons. +fn format_table(block: &[String]) -> Vec { + let rows: Vec> = block.iter().map(|l| table_cells(l)).collect(); + let aligns: Vec = rows[1] + .iter() + .map(|c| match (c.starts_with(':'), c.ends_with(':')) { + (true, true) => Align::Center, + (true, false) => Align::Left, + (false, true) => Align::Right, + (false, false) => Align::None, + }) + .collect(); + let ncols = rows.iter().map(|r| r.len()).max().unwrap_or(0).max(aligns.len()); + + // Column widths from content rows (min 3 so the separator stays readable). + let mut width = vec![3usize; ncols]; + for (ri, row) in rows.iter().enumerate() { + if ri == 1 { + continue; // the separator's own width doesn't constrain the column + } + for (ci, cell) in row.iter().enumerate() { + width[ci] = width[ci].max(cell.chars().count()); + } + } + let align_of = |ci: usize| aligns.get(ci).copied().unwrap_or(Align::None); + + let mut out = Vec::with_capacity(rows.len()); + for (ri, row) in rows.iter().enumerate() { + let cells: Vec = (0..ncols) + .map(|ci| { + let w = width[ci]; + if ri == 1 { + match align_of(ci) { + Align::Left => format!(":{}", "-".repeat(w - 1)), + Align::Right => format!("{}:", "-".repeat(w - 1)), + Align::Center => format!(":{}:", "-".repeat(w - 2)), + Align::None => "-".repeat(w), + } + } else { + pad_cell(row.get(ci).map(String::as_str).unwrap_or(""), w, align_of(ci)) + } + }) + .collect(); + out.push(format!("| {} |", cells.join(" | "))); + } + out +} + +/// Pad `cell` to `w` columns per `align` (left/none pad right, right pads left, +/// center splits). Over-wide cells are returned unchanged. +fn pad_cell(cell: &str, w: usize, align: Align) -> String { + let len = cell.chars().count(); + if len >= w { + return cell.to_string(); + } + let pad = w - len; + match align { + Align::Right => format!("{}{cell}", " ".repeat(pad)), + Align::Center => { + let l = pad / 2; + format!("{}{cell}{}", " ".repeat(l), " ".repeat(pad - l)) + } + _ => format!("{cell}{}", " ".repeat(pad)), + } +}