From baf9715570d77ce05ad7d4c98c7bde4d1a6f21f5 Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Sun, 12 Jul 2026 10:09:43 +0200 Subject: [PATCH] feat(editor): hint the matching snippet in the panel on typing pause MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Snapshot the snippet name inline Tab would expand into a panel field in refresh_stats, so it rides the typing-pause throttle rather than repainting per keystroke. Draw it as `» name` on the row above the mode line (Latin-9 has no tab glyph), sharing the slot with the NO KBD flag, which can't co-occur since the hint means you're typing. --- docs/typoena-snippets.md | 7 +-- docs/v0.6-markdown.md | 12 +++-- editor/src/lib.rs | 101 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 108 insertions(+), 12 deletions(-) diff --git a/docs/typoena-snippets.md b/docs/typoena-snippets.md index 8ebe6c0..1b900ca 100644 --- a/docs/typoena-snippets.md +++ b/docs/typoena-snippets.md @@ -83,9 +83,10 @@ handler, alongside the existing list-continuation transform.) On a **typing pause** — the same throttle as the word-count / cursor refresh, so never a per-keystroke e-ink flash — if the word before the caret is a prefix, the -right side panel shows a quiet hint (`↹ fiche de lecture`). The panel is ~17 -columns, so the hint is the **snippet name / first line**, not the whole body; -the full preview is what the `$` palette is for. +right side panel shows a quiet hint (`» Book notes`), on the row above the mode +line. The panel is ~15 columns, so the hint is the **snippet name**, not the whole +body; the full preview is what the `$` palette is for. (The marker is `»`, not a +tab glyph: the panel font is ISO-8859-15, which has no `↹`.) ### `$` palette (browse + insert) diff --git a/docs/v0.6-markdown.md b/docs/v0.6-markdown.md index 62c18ef..45d0f38 100644 --- a/docs/v0.6-markdown.md +++ b/docs/v0.6-markdown.md @@ -34,11 +34,15 @@ behaviour. handler alongside the existing `list_marker` transform (`expand_snippet(word) -> Option`). Tab already arrives as `Key::Char('\t')`, so no new key event. -- [ ] **Hint-on-pause.** On the typing pause (same throttle as the word-count / +- [x] **Hint-on-pause.** On the typing pause (same throttle as the word-count / cursor refresh — never a per-keystroke repaint), if the word before the - caret is a prefix, the right side panel shows a quiet hint. The panel is - ~17 cols, so the hint is the snippet **name / first line**, not the whole - body — the full preview is the `$` palette's job. + caret is a prefix, the right side panel shows a quiet hint (`» name`, on the + row above the mode line, sharing the slot with the `NO KBD` flag — the two + never co-occur since the hint means you're typing). The panel is ~15 cols, + so the hint is the snippet **name**, not the whole body — the full preview is + the `$` palette's job. Snapshotted in `refresh_stats` so it rides the pause, + and the firmware's Insert-pause repaint already carries it (no firmware + change). Latin-9 has no `↹` glyph, hence the `»` marker. - [x] **`$` palette (browse + insert).** `Cmd-P` then `$` switches the palette to the snippet list (the same sigil mechanism as `>`). Fuzzy-matches name / prefix / description; Enter inserts the body at the caret and starts the diff --git a/editor/src/lib.rs b/editor/src/lib.rs index d5494dc..9807007 100644 --- a/editor/src/lib.rs +++ b/editor/src/lib.rs @@ -723,6 +723,11 @@ pub struct Editor { /// shift by the edit's length delta (they are all at/after the caret), so they /// track the text; Tab pops the next one, and leaving Insert clears them. snippet_stops: Vec, + /// Snapshot of the snippet name inline Tab would expand right now (the word + /// before the caret is a prefix), or `None`. Refreshed by + /// [`refresh_stats`](Self::refresh_stats) on the typing pause — the same + /// throttle as the word count — so the panel hint never repaints per keystroke. + snippet_hint: Option, } /// A resident-but-inactive buffer: everything needed to restore a file's editing @@ -798,6 +803,7 @@ impl Editor { palette_sel: 0, snippets: Vec::new(), snippet_stops: Vec::new(), + snippet_hint: None, } } @@ -887,11 +893,30 @@ impl Editor { self.scroll_top } - /// Recompute the panel word-count snapshot from the buffer. The main loop - /// calls this on a typing pause and on non-Insert actions, so the panel - /// count stays current without repainting on every keystroke. + /// Recompute the throttled panel snapshots from the buffer: the word count and + /// the inline-snippet hint. The main loop calls this on a typing pause and on + /// non-Insert actions, so the panel stays current without repainting on every + /// keystroke. pub fn refresh_stats(&mut self) { self.shown_words = self.word_count(); + self.snippet_hint = self.current_snippet_hint(); + } + + /// The snippet name inline Tab would expand at the caret right now, or `None`. + /// Only in Insert mode outside a live tab-stop session (mid-session Tab + /// advances stops, it doesn't expand), and only when the word immediately + /// before the caret is exactly a snippet prefix — the same test + /// [`try_expand_snippet`](Self::try_expand_snippet) uses. Snapshotted into + /// [`snippet_hint`](Self::snippet_hint) by [`refresh_stats`](Self::refresh_stats). + fn current_snippet_hint(&self) -> Option { + if self.mode != Mode::Insert || !self.snippet_stops.is_empty() { + return None; + } + let (_, word) = self.word_before_caret()?; + self.snippets + .iter() + .find(|s| s.prefix == word) + .map(|s| s.name.clone()) } /// Tell the editor whether a keyboard is attached (for the panel flag). @@ -3158,8 +3183,10 @@ impl Editor { } } - // Keyboard-disconnect flag, just above the mode line, shown only while - // the keyboard is dropped. Latin-9 has no ⌨/✗ glyph, so plain text. + // Just above the mode line: the keyboard-disconnect flag, or — while + // typing a recognised prefix — a quiet snippet hint. Mutually exclusive: + // the hint means you're typing, which needs the keyboard. Latin-9 has no + // ⌨/✗ or ↹ glyph, so the flag is plain text and the hint leads with `»`. if !self.keyboard_present { Text::with_baseline( "NO KBD", @@ -3169,6 +3196,16 @@ impl Editor { ) .draw(f) .unwrap(); + } else if let Some(name) = &self.snippet_hint { + let hint: String = format!("» {name}").chars().take(PANEL_COLS).collect(); + Text::with_baseline( + &hint, + Point::new(PANEL_X, HEIGHT as i32 - 2 * PANEL_CH), + style, + Baseline::Top, + ) + .draw(f) + .unwrap(); } // Mode indicator + pending count/operator echo at the panel's bottom- @@ -5598,4 +5635,58 @@ mod tests { let mut filtered = snippet_palette(TWO_SNIPPETS, "$link"); let _ = filtered.draw(true); } + + // ---- hint-on-pause ---- + + /// Type `word` into a fresh Insert-mode buffer with the two snippets loaded. + fn typed_in_insert(word: &str) -> Editor { + let mut e = with_snippets(TWO_SNIPPETS); + e.handle(Key::Char('i')); + for c in word.chars() { + e.handle(Key::Char(c)); + } + e + } + + #[test] + fn pause_hint_names_the_snippet_a_prefix_would_expand() { + let mut e = typed_in_insert("link"); + assert_eq!(e.snippet_hint, None); // not computed per keystroke + e.refresh_stats(); // the typing-pause throttle + assert_eq!(e.snippet_hint.as_deref(), Some("Markdown link")); + } + + #[test] + fn pause_hint_is_absent_without_a_matching_prefix() { + let mut e = typed_in_insert("zz"); + e.refresh_stats(); + assert_eq!(e.snippet_hint, None); + } + + #[test] + fn pause_hint_clears_when_leaving_insert() { + let mut e = typed_in_insert("link"); + e.refresh_stats(); + assert!(e.snippet_hint.is_some()); + e.handle(Key::Escape); // → Normal + e.refresh_stats(); // the main loop refreshes on non-Insert actions + assert_eq!(e.snippet_hint, None); + } + + #[test] + fn pause_hint_is_absent_during_a_live_session() { + let mut e = typed_in_insert("link"); + e.handle(Key::Char('\t')); // expand → session live, caret on $1 + assert!(!e.snippet_stops.is_empty()); + e.refresh_stats(); + assert_eq!(e.snippet_hint, None, "mid-session Tab advances, not expands"); + } + + #[test] + fn draw_with_a_pause_hint_does_not_panic() { + let mut e = typed_in_insert("link"); + e.refresh_stats(); + assert!(e.snippet_hint.is_some()); + let _ = e.draw(true); // the `» name` panel row must render cleanly + } }