Compare commits

...

2 Commits

Author SHA1 Message Date
Julien Calixte
97216db0c0 docs: record line-number gutter and v0.5 line_numbers pref
Mark the v0.2 gutter built + host-tested (Spike 13 bench check still pending),
add the v0.5 .typoena.toml line_numbers toggle, and update CONTEXT.md screen
regions for the rebalanced 63-col writing region / 25-col side panel.
2026-07-11 18:59:52 +02:00
Julien Calixte
8b30658275 feat(editor): absolute line-number gutter
Reserve a left gutter for absolute line numbers, drawn on each logical line's
first display row and blank on wrapped continuation rows. Gutter width tracks
the buffer's line count (2 digits + separator, widening past 99 lines) and
steals its columns from the soft-wrap. Widen the writing region 60->63 cols so
the gutter doesn't narrow the text: a file up to 99 lines keeps a full 60-col
text column, at the cost of the side panel shrinking 30->25 cols.
2026-07-11 18:59:46 +02:00
3 changed files with 131 additions and 32 deletions

View File

@@ -60,13 +60,15 @@ into user-facing language).
**Writing column**:
The left region of the panel showing the text being edited — the _only_ region
that repaints per keystroke. ~60 columns, full panel height; straddles the
driver's `x = 396` seam invisibly.
that repaints per keystroke. A 63-col region split into a **line-number gutter**
(absolute numbers, 24 cols wide, sized to the buffer's line count) and the text
column it steals from (~60 cols for a file ≤ 99 lines). Full panel height;
straddles the driver's `x = 396` seam invisibly.
_Avoid_: edit area, text area, main pane (superseded — they named the old
full-width text region before the side panel carved out its right edge).
**Side panel**:
The right region (~150 px / ~20 cols, full height) holding all metadata:
The right region (~160 px / ~25 cols, full height) holding all metadata:
filename + dirty dot, word count, elapsed time, clock, Wi-Fi,
keyboard-disconnect flag, publish state, and the mode indicator at its
bottom-left. Sits entirely in the master half

View File

@@ -76,7 +76,7 @@ note = ": command-line mechanism and :fmt done early; Visual mode not started."
name = "v0.5 palette + multi-file"
start = 2026-09-07
original = 2026-09-28
note = "Also adds the git-tracked .typoena.toml preferences file (save_on_idle, auto_sync cadence) and the palette `>` command mode that edits it live."
note = "Also adds the git-tracked .typoena.toml preferences file (save_on_idle, auto_sync cadence, line_numbers) and the palette `>` command mode that edits it live."
[[feature]]
name = "v0.6 markdown"
@@ -191,9 +191,10 @@ Out of scope: Vim, palette, multiple files, branches, conflict handling.
**Status:** navigation done in core; the **UTF-8-correct buffer landed
2026-07-11** (hardware-verified) and **`Ctrl-d/u` half-page scroll landed
2026-07-11** (host-tested). Remaining = the line-number gutter. Shipped early
beyond scope: a read-only **View** mode and the full `d`/`c` operator +
text-object grammar (see v0.3 / v0.4).
2026-07-11** (host-tested). The **absolute line-number gutter is built +
host-tested 2026-07-11**; only Spike 13's on-panel refresh check remains.
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
- [x] Movement: `h j k l`, `w b e`, `0 $`, `gg G`, `Ctrl-d Ctrl-u`. `Ctrl-d/u`
@@ -202,10 +203,16 @@ text-object grammar (see v0.3 / v0.4).
intents in the keymap, caret moves and the viewport follows.
- [x] `i a o O A` to enter Insert
- [x] `Esc` returns to Normal
- [ ] Line numbers in the left gutter: **absolute** — Spike 13 first. Relative
numbering was dropped (2026-07-11): renumbering the whole gutter on every
`j`/`k` burns the e-ink ghosting budget for no proportionate gain, whereas
absolute renumbers only the rows below an edit.
- [~] Line numbers in the left gutter: **absolute**, built + host-tested
2026-07-11numbered on a logical line's first display row, blank on
wrapped continuation rows; the gutter width tracks the buffer's line count
(2 digits + separator, widening past 99 lines) and steals its columns from
the soft-wrap. **Always on** in v0.2; the on/off toggle rides the v0.5
`.typoena.toml` prefs (below). Relative numbering was dropped (2026-07-11):
renumbering the whole gutter on every `j`/`k` burns the e-ink ghosting
budget for no proportionate gain, whereas absolute renumbers only the rows
below an edit. Spike 13's on-panel check (a single-line edit repaints only
rows at/below it, no extra full refresh) is still pending.
- [x] Groundwork — UTF-8-correct buffer: caret motions and edits step by
character, not byte (dropped the ASCII == byte-offset assumption), so every
motion stays correct with accented input. **Done 2026-07-11** alongside
@@ -290,6 +297,9 @@ buffer-lifecycle risk first).
- [ ] `save_on_idle` (bool, default `true`) — auto-save the current buffer on
the existing idle pause (the ≥ 1 s typing-pause the panel already uses
for its refresh), so `:w` becomes optional rather than required.
- [ ] `line_numbers` (bool, default `true`) — show the absolute line-number
gutter (built always-on in v0.2). Off reclaims the gutter's columns for
text; the palette `> line numbers: on/off` command toggles it live.
- [ ] `auto_sync` (duration string, default `"10m"`; `"0"` / omitted
disables; **min clamp ~`"2m"`** so a palette typo can't drain the
battery) — a *max-staleness cap*, not a wall-clock timer:

View File

@@ -25,12 +25,17 @@ use keymap::Key;
/// FONT_10X20 cell size (writing column) and the grid it tiles into.
pub const CW: i32 = 10;
pub const CH: i32 = 20;
/// Writing-column width, in characters. The panel is split into a left
/// **writing column** (this wide) and a right **side panel** for metadata (see
/// CONTEXT.md § Screen regions). 60 cols × 10 px = 600 px of text; the driver's
/// `x = 396` seam runs through it invisibly. The remaining 192 px sit entirely
/// in the master half (right of the seam) and hold the side panel.
const WRITE_COLS: usize = 60;
/// Writing-region width, in characters: the left region holding the
/// line-number gutter **and** the text column (the gutter steals from it — see
/// [`Editor::text_cols`]). The right **side panel** holds metadata (see
/// CONTEXT.md § Screen regions). 63 cols × 10 px = 630 px; the driver's
/// `x = 396` seam runs through it invisibly. The remaining 162 px (right of the
/// divider) hold the ~25-col side panel. Widened from 60 so the gutter doesn't
/// narrow the text: a ≤ 99-line file keeps a full 60-col text column.
const WRITE_COLS: usize = 63;
/// Minimum digit columns in the line-number gutter (before the 1-col separator).
/// Files up to 99 lines still get a 2-wide gutter so short notes don't jitter.
const GUTTER_MIN_DIGITS: usize = 2;
/// Visible writing rows. 13 × 20 px = 260 px; the bottom 12 px is the transient
/// `:` command line (the only thing left of the old status band).
const ROWS: usize = (HEIGHT / 20) as usize; // 13
@@ -40,12 +45,12 @@ const ROWS: usize = (HEIGHT / 20) as usize; // 13
const HALF_PAGE: usize = ROWS / 2; // 6
/// x of the 1 px rule dividing writing column from side panel, and the left edge
/// of panel text (a small gutter past the rule).
const DIVIDER_X: i32 = WRITE_COLS as i32 * CW; // 600
const PANEL_X: i32 = DIVIDER_X + 8; // 608
const DIVIDER_X: i32 = WRITE_COLS as i32 * CW; // 630
const PANEL_X: i32 = DIVIDER_X + 8; // 638
/// Side-panel text width in 6 px (`FONT_6X10`) columns, for clamping panel
/// strings — the snackbar notice, word count — so they never draw past the
/// right edge of the panel.
const PANEL_COLS: usize = (WIDTH as usize - PANEL_X as usize) / 6; // 30
const PANEL_COLS: usize = (WIDTH as usize - PANEL_X as usize) / 6; // 25
/// Tab stop, in spaces. Tabs never enter the buffer — they expand on insert so
/// the buffer stays 1 char = 1 column.
const TAB: &str = " ";
@@ -919,14 +924,39 @@ impl Editor {
// --- Rendering ---------------------------------------------------------
/// Number of logical lines in the buffer (1 + newline count). Used to size
/// the line-number gutter.
fn logical_lines(&self) -> usize {
self.text.bytes().filter(|&b| b == b'\n').count() + 1
}
/// Width of the absolute line-number gutter, in display columns: enough
/// digits for the buffer's largest line number (min [`GUTTER_MIN_DIGITS`])
/// plus a 1-column separator before the text. Sized from the *total* line
/// count, not the visible range, so it stays fixed while scrolling — only
/// crossing a power of ten (100, 1000, …) reflows the wrap, which is rare.
fn gutter_cols(&self) -> usize {
let digits = self.logical_lines().to_string().len().max(GUTTER_MIN_DIGITS);
digits + 1
}
/// Character columns left for text once the gutter is reserved. The writing
/// region is fixed at [`WRITE_COLS`]; the gutter steals from it, so text
/// soft-wraps narrower.
fn text_cols(&self) -> usize {
WRITE_COLS - self.gutter_cols()
}
/// Wrap the buffer into display lines, tracking each line's buffer offset.
/// Soft-wrap at word boundaries: a logical line too long for `WRITE_COLS`
/// breaks at the last space that fits, so words are never split — except a
/// single word wider than the writing column, hard-broken at `WRITE_COLS`.
/// Soft-wrap at word boundaries: a logical line too long for [`text_cols`]
/// (the writing width left after the line-number gutter) breaks at the last
/// space that fits, so words are never split — except a single word wider
/// than the column, hard-broken at [`text_cols`].
/// Wrapping counts characters (one per display cell), while `Line.start` is
/// a byte offset into the buffer, so caret math stays correct for multi-byte
/// (accented) characters.
fn layout(&self) -> Vec<Line> {
let cols = self.text_cols(); // writing width after the gutter is reserved
let mut lines: Vec<Line> = Vec::new();
let mut base = 0usize; // byte offset of the current logical line's start
for logical in self.text.split('\n') {
@@ -938,12 +968,12 @@ impl Editor {
let mut byte = 0usize; // byte offset of chars[c] within `logical`
while c < chars.len() {
let remaining = chars.len() - c;
let take = if remaining <= WRITE_COLS {
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 + WRITE_COLS;
let window = c + cols;
let mut brk = None;
let mut p = window;
while p > c {
@@ -955,7 +985,7 @@ impl Editor {
}
match brk {
Some(sp) if sp > c => sp + 1 - c,
_ => WRITE_COLS,
_ => cols,
}
};
lines.push(Line {
@@ -1031,24 +1061,47 @@ impl Editor {
let mut f = Frame::new_white();
let text_style = MonoTextStyle::new(&FONT_10X20, BinaryColor::On);
let gutter = self.gutter_cols();
let cols = WRITE_COLS - gutter; // text columns after the gutter
let gx = gutter as i32 * CW; // text (and cursor) x-origin, past the gutter
let digits = gutter - 1; // number field width; the last col is the separator
let end = (self.scroll_top + ROWS).min(lay.len());
// Absolute line number of the first visible row's logical line, then
// bumped as later logical lines scroll into view.
let mut line_no = self.text.as_bytes()[..lay[self.scroll_top.min(lay.len() - 1)].start]
.iter()
.filter(|&&b| b == b'\n')
.count()
+ 1;
for (vis, li) in (self.scroll_top..end).enumerate() {
let y = vis as i32 * CH;
Text::with_baseline(&lay[li].text, Point::new(0, y), text_style, Baseline::Top)
// Number a logical line only on its first display row; wrapped
// continuation rows leave the gutter blank.
let first_row = lay[li].start == self.line_start(lay[li].start);
if li > self.scroll_top && first_row {
line_no += 1;
}
if first_row {
let label = format!("{line_no:>digits$}");
Text::with_baseline(&label, Point::new(0, y), text_style, Baseline::Top)
.draw(&mut f)
.unwrap();
}
Text::with_baseline(&lay[li].text, Point::new(gx, 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)
Text::with_baseline(&lay[li].text, Point::new(gx + 1, y), text_style, Baseline::Top)
.draw(&mut f)
.unwrap();
}
}
if crow >= self.scroll_top && crow < self.scroll_top + ROWS {
let x = ccol.min(WRITE_COLS - 1) as i32 * CW;
let x = gx + ccol.min(cols - 1) as i32 * CW;
let y = (crow - self.scroll_top) as i32 * CH;
match self.mode {
Mode::Normal if cursor_on => {
@@ -1592,10 +1645,11 @@ mod tests {
/// within the single line at all.
#[test]
fn half_page_down_steps_display_rows_within_a_wrapped_line() {
let mut e = Editor::with_text("a".repeat(WRITE_COLS * 10)); // 10 wrapped rows
let mut e = Editor::with_text("a".repeat(WRITE_COLS * 10)); // one long wrapped line
let cols = e.text_cols(); // wrap width shrinks by the gutter
e.caret = 0;
e.handle(Key::HalfPageDown);
assert_eq!(e.caret, WRITE_COLS * HALF_PAGE); // down HALF_PAGE display rows
assert_eq!(e.caret, cols * HALF_PAGE); // down HALF_PAGE *display* rows
// Contrast: `j` on the same single logical line is a no-op.
let mut j = Editor::with_text("a".repeat(WRITE_COLS * 10));
@@ -1608,7 +1662,7 @@ mod tests {
#[test]
fn half_page_up_is_the_inverse_within_a_wrapped_line() {
let mut e = Editor::with_text("a".repeat(WRITE_COLS * 10));
e.caret = WRITE_COLS * HALF_PAGE;
e.caret = e.text_cols() * HALF_PAGE; // start on a display-row boundary
e.handle(Key::HalfPageUp);
assert_eq!(e.caret, 0);
}
@@ -1671,6 +1725,39 @@ mod tests {
assert_eq!(e.mode(), Mode::Insert);
}
// ---- Absolute line-number gutter (v0.2) ----
#[test]
fn gutter_is_two_digits_plus_separator_for_small_files() {
let e = Editor::with_text("one\ntwo\nthree".to_string()); // 3 logical lines
assert_eq!(e.logical_lines(), 3);
assert_eq!(e.gutter_cols(), 3); // 2 digit cols + 1 separator
assert_eq!(e.text_cols(), WRITE_COLS - 3);
}
#[test]
fn gutter_widens_past_ninety_nine_lines() {
let e = Editor::with_text("x\n".repeat(120)); // 121 logical lines
assert_eq!(e.gutter_cols(), 4); // 3 digit cols + 1 separator
assert_eq!(e.text_cols(), WRITE_COLS - 4);
}
#[test]
fn gutter_narrows_the_soft_wrap_width() {
let e = Editor::with_text("a".repeat(WRITE_COLS)); // 60 chars, one logical line
let cols = e.text_cols();
assert!(cols < WRITE_COLS); // the gutter stole columns
let lay = e.layout();
assert_eq!(lay[0].text.chars().count(), cols); // first row fills the text width
assert!(lay.len() >= 2); // 60 chars no longer fit one row
}
#[test]
fn draw_with_gutter_produces_a_full_frame() {
let mut e = Editor::with_text("line one\nline two\nline three".to_string());
assert_eq!(e.draw(true).bytes().len(), display::FB_BYTES);
}
#[test]
fn text_getter_reflects_edits() {
let e = typed("hello");