Compare commits
2 Commits
2aa4032e1f
...
77768cc85f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77768cc85f | ||
|
|
b746d6e3f9 |
100
docs/roadmap.md
100
docs/roadmap.md
@@ -5,36 +5,57 @@ The macro-plan (Gantt) lives in the [README](../README.md#roadmap); this file
|
||||
holds the per-version scope. The user-facing requirements and engineering
|
||||
targets each release feeds into are tracked in [`qfd.md`](qfd.md).
|
||||
|
||||
## Status — synced 2026-07-07
|
||||
|
||||
The editor **core** (`firmware/src/editor.rs`) has been built 2–3 versions
|
||||
ahead of the device **releases**. No release has shipped: v0.1's hardware gate
|
||||
(SD, splash, wiring the git/save path into the app binary) is still open, even
|
||||
though v0.2 navigation and most of v0.6 Markdown already run. Version numbers
|
||||
are unchanged — they track shippable device releases, not core progress.
|
||||
|
||||
Marks: `[x]` done in core · `[~]` partially done · `[ ]` not started. An
|
||||
inline `(✓)` marks the done half of a split item.
|
||||
|
||||
---
|
||||
|
||||
## v0.1 — MVP: "it writes, it pushes" — [ ]
|
||||
## v0.1 — MVP: "it writes, it pushes" — [~]
|
||||
|
||||
The minimum thing that justifies the hardware existing. Full design:
|
||||
[product](v0.1-mvp-product.md) · [technical](v0.1-mvp-technical.md).
|
||||
|
||||
- [ ] ESP32-S3 boots, e-ink shows Typoena splash + boot log
|
||||
- [ ] USB host enumerates the Nuphy, key events reach the editor
|
||||
- [ ] One hard-coded file (`/sd/repo/notes.md`) opens on boot
|
||||
- [ ] Insert-only editing (no modes yet), backspace, enter, arrow keys
|
||||
- [ ] Line wrap, no line numbers yet
|
||||
- [ ] Save on `Ctrl-S` → SD
|
||||
- [ ] Wi-Fi credentials + remote URL + PAT + author baked into the binary at
|
||||
**Status:** core editing + partial refresh run on device. **Blocked** on three
|
||||
integration items: SD (Spike 3 — awaiting a compatible ≤32 GB card), the boot
|
||||
splash (Spike 9), and wiring the git/save path into the app binary — today it
|
||||
lives in the `git_sync` / `sd_fat` spike bins, not `main.rs`.
|
||||
|
||||
- [~] ESP32-S3 boots (✓); e-ink shows Typoena splash + boot log — splash pending Spike 9
|
||||
- [x] USB host enumerates the Nuphy, key events reach the editor (Spike 4)
|
||||
- [ ] One hard-coded file (`/sd/repo/notes.md`) opens on boot — SD spike-only, not in `main.rs`
|
||||
- [x] Insert-only editing, backspace, enter, arrow keys — modal editor overshot this early (see v0.2)
|
||||
- [x] Line wrap, no line numbers yet — soft-wrap done early (see v0.6)
|
||||
- [ ] Save on `Ctrl-S` → SD — SD blocked, not wired to `main.rs`
|
||||
- [x] Wi-Fi credentials + remote URL + PAT + author baked into the binary at
|
||||
build time via env vars (no NVS, no on-device provisioning UI in v0.1)
|
||||
- [ ] `Ctrl-G` runs: `git add .` → commit with an ISO-8601 timestamp message →
|
||||
- [~] `Ctrl-G` runs: `git add .` → commit with an ISO-8601 timestamp message →
|
||||
`git push`; on push failure, `git pull --no-edit` then retry the push
|
||||
(no-op short-circuit when nothing is staged). PAT from first-run setup.
|
||||
- [ ] Partial refresh on edits; full refresh on save
|
||||
(no-op short-circuit when nothing is staged). Proven on device in the
|
||||
`git_sync` spike (✓); not yet wired to the editor.
|
||||
- [~] Partial refresh on edits (✓ Spike 5); full refresh on save — save not wired yet
|
||||
|
||||
Out of scope: Vim, palette, multiple files, branches, conflict handling.
|
||||
|
||||
## v0.2 — Vim navigation — [ ]
|
||||
## v0.2 — Vim navigation — [~]
|
||||
|
||||
- [ ] Mode state machine (Normal / Insert), mode indicator in the side panel
|
||||
- [ ] Movement: `h j k l`, `w b e`, `0 $`, `gg G`, `Ctrl-d Ctrl-u`
|
||||
- [ ] `i a o O A` to enter Insert
|
||||
- [ ] `Esc` returns to Normal
|
||||
**Status:** navigation done in core; remaining = `Ctrl-d/u`, the line-number
|
||||
gutter, and the UTF-8 buffer. Shipped early beyond scope: a read-only **View**
|
||||
mode and the full `d`/`c` operator + text-object grammar (see v0.3 / v0.4).
|
||||
|
||||
- [x] Mode state machine (Normal / Insert / View), mode indicator in the status strip
|
||||
- [~] Movement: `h j k l`, `w b e`, `0 $`, `gg G` (✓); `Ctrl-d Ctrl-u` remain
|
||||
- [x] `i a o O A` to enter Insert
|
||||
- [x] `Esc` returns to Normal
|
||||
- [ ] Line numbers in the left gutter: relative in Normal mode (current line
|
||||
shown as its absolute number), absolute in Insert mode
|
||||
shown as its absolute number), absolute in Insert mode — Spike 13 first
|
||||
- [ ] Groundwork — UTF-8-correct buffer: caret motions and edits step by
|
||||
character, not byte (drop the ASCII == byte-offset assumption in
|
||||
`editor.rs`), so every motion added here and later stays correct once
|
||||
@@ -44,6 +65,8 @@ Out of scope: Vim, palette, multiple files, branches, conflict handling.
|
||||
|
||||
## v0.2.5 — International input — [ ]
|
||||
|
||||
**Status:** not started (depends on the v0.2 UTF-8-correct buffer).
|
||||
|
||||
A small focused release between navigation and editing. US-International
|
||||
dead-key accent composition, resolved in the keyboard layer (`usb_kbd.rs`) so
|
||||
the editor still receives a single `Key::Char`. Builds on the v0.2
|
||||
@@ -58,19 +81,40 @@ UTF-8-correct buffer and the ISO-8859-15 render font.
|
||||
accent as its literal first
|
||||
- [ ] Pending-accent indicator in the side-panel status strip
|
||||
|
||||
## v0.3 — Vim editing — [ ]
|
||||
## v0.3 — Vim editing — [~]
|
||||
|
||||
- [ ] `x dd yy p P`, `dw dd d$`, repeat with `.`
|
||||
**Status:** deletes and counts done; **yank/paste, undo/redo, and `.` repeat
|
||||
remain** — there is no register or recorded-op mechanism yet. The `d`/`c`
|
||||
operator grammar and text objects landed here ahead of schedule (the roadmap
|
||||
had scheduled only `dd`/`dw`/`d$`).
|
||||
|
||||
- [~] `x dd`, `dw dd d$` (✓); `yy p P` and repeat with `.` remain (need a register / recorded op)
|
||||
- [ ] Undo / redo (`u`, `Ctrl-r`) — bounded history in PSRAM
|
||||
- [ ] Numeric prefixes (`3dd`, `5j`)
|
||||
- [x] Numeric prefixes (`3dd`, `5j`)
|
||||
- [x] Ahead of schedule: `c` change operator + text objects
|
||||
(`ciw`, `di(`, `ca"`, … — inner/around, nesting-aware)
|
||||
|
||||
## v0.4 — Visual mode + ex commands — [ ]
|
||||
## v0.4 — Visual mode + ex commands — [~]
|
||||
|
||||
**Status:** the `:` command-line mechanism is built (Command mode + status-strip
|
||||
echo), but only `:fmt` exists — `:w :q :wq :e` remain. Visual mode is not
|
||||
started.
|
||||
|
||||
**DECISION (2026-07-07):** `v`/`V` = **Visual** selection (vim-standard). The
|
||||
read-only **View** (reading/scroll) mode currently bound to `v`/`V` moves off
|
||||
those keys and gets its own trigger (exact key TBD when Visual lands). View mode
|
||||
stays — it just frees `v`/`V` for Visual.
|
||||
|
||||
- [ ] Visual char (`v`) and line (`V`) modes, `y d c` on selections
|
||||
- [ ] `:` command line: `:w :q :wq :e <path>`
|
||||
- [~] `:` command line: `:w :q :wq :e <path>` (mechanism ✓; these commands remain)
|
||||
- [x] Ahead of schedule / unscheduled: `:fmt` Markdown formatter
|
||||
(table alignment, blank-line collapse, trailing-whitespace strip)
|
||||
|
||||
## v0.5 — File palette + multi-file — [ ]
|
||||
|
||||
**Status:** not started (Spikes 11 + 14 retire the panel-mechanism and
|
||||
buffer-lifecycle risk first).
|
||||
|
||||
- [ ] `Ctrl-P` opens fuzzy file palette over **both** `/sd/repo/` and
|
||||
`/sd/local/`, with a scope marker (e.g. `[git]` / `[local]`) per result
|
||||
- [ ] Open, switch, close buffers (keep ≤ 3 in memory)
|
||||
@@ -84,15 +128,19 @@ UTF-8-correct buffer and the ISO-8859-15 render font.
|
||||
more than one dirty Tracked file (e.g. `"publishing 3 files: abc1234"`),
|
||||
so workspace-scoped behaviour stays visible to the user
|
||||
|
||||
## v0.6 — Markdown affordances — [ ]
|
||||
## v0.6 — Markdown affordances — [~]
|
||||
|
||||
- [ ] Heading lines bolded in render
|
||||
- [ ] List continuation on Enter inside `- ` / `1. `
|
||||
- [ ] Soft-wrap at word boundaries
|
||||
**Status:** done early — only the 80-col ruler remains.
|
||||
|
||||
- [x] Heading lines bolded in render (faux-bold double-strike)
|
||||
- [x] List continuation on Enter inside `- ` / `1. ` (with empty-item exit)
|
||||
- [x] Soft-wrap at word boundaries
|
||||
- [ ] Optional column ruler at 80
|
||||
|
||||
## v0.7 — Search + better git — [ ]
|
||||
|
||||
**Status:** not started.
|
||||
|
||||
- [ ] `/` forward search, `n N`
|
||||
- [ ] `:Gpull` (fetch + fast-forward only; refuse on conflict and surface it)
|
||||
|
||||
|
||||
@@ -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<bool>,
|
||||
/// 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<Line> {
|
||||
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<Line> = 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<char> = 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: <digits>`. ` → 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<String> = 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<String> = 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<String> = 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<String> {
|
||||
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<usize> {
|
||||
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<String> {
|
||||
let rows: Vec<Vec<String>> = block.iter().map(|l| table_cells(l)).collect();
|
||||
let aligns: Vec<Align> = 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<String> = (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)),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user