feat(editor): hint the matching snippet in the panel on typing pause

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.
This commit is contained in:
Julien Calixte
2026-07-12 10:09:43 +02:00
parent 4aaf89d977
commit baf9715570
3 changed files with 108 additions and 12 deletions

View File

@@ -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)

View File

@@ -34,11 +34,15 @@ behaviour.
handler alongside the existing `list_marker` transform
(`expand_snippet(word) -> Option<Snippet>`). 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

View File

@@ -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<usize>,
/// 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<String>,
}
/// 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<String> {
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
}
}