lattice_host/decline.rs
1//! AP.0.2 — the layer peel behind `Effect::Declined`, in one place.
2//!
3//! A mode can bind a chord it only sometimes wants. auto-pair binds `(` and
4//! `<BS>`; table-mode binds `<Tab>`. When the situation is not theirs, the
5//! action returns [`Effect::Declined`](lattice_grammar::Effect::Declined) —
6//! "I did nothing" — and the chord must be re-resolved **as if that mode's
7//! layer were not there**, so it reaches whatever is underneath. A declined
8//! `(` types a bracket; a declined `<Tab>` folds.
9//!
10//! ## Why this is a type and not a loop at each call site
11//!
12//! It was a loop at each call site, twice, and the third caller — the GPUI
13//! peer — had neither. Every key auto-pair binds (`( [ { ) ] } " ' \``, and
14//! backspace) was therefore dead in that renderer, and `<Tab>` never fell
15//! through to org's fold cycle. The TUI's copy carried the comment "mirrors
16//! the host peel exactly (GPUI rides that path)", which was not true and
17//! could not be checked.
18//!
19//! What varies between callers is real: each builds its translate context
20//! from a different place and applies the resulting action through its own
21//! path (the peers have renderer-coupled intercepts the host does not). What
22//! does NOT vary is the walk — which layer to drop, what the prefix is, and
23//! when to stop — and that is what lives here. Two bugs came out of getting
24//! exactly those wrong:
25//!
26//! - **Dropping every layer at once.** `active_minor_modes: &[]` means a
27//! second declining layer never sees the chord, so table-mode's `<Tab>`
28//! declining outside a table skipped org-mode's fold cycle entirely and
29//! landed on the builtin jump-list. Layers compose; a chain of length two
30//! is the first case anyone writes.
31//! - **Losing the prefix.** Re-translating the final chord alone turned a
32//! declined `<leader>oJ` into vim's `J`, which joined two lines. The peel
33//! re-resolves the SEQUENCE.
34
35use lattice_grammar::ModalState;
36use lattice_mode::ModeId;
37
38use crate::chord::KeyChord;
39use crate::keymap::BindingMode;
40use crate::keymap_registry::KeymapHandle;
41
42/// The [`BindingMode`] a modal state resolves chords in.
43///
44/// One definition: the host's dispatcher, the TUI's runtime and this walk all
45/// used to carry their own copy of this match, and a mode added to one of
46/// them would silently resolve against the wrong keymap in the others.
47pub fn binding_mode_for(modal: ModalState) -> BindingMode {
48 match modal {
49 ModalState::Insert => BindingMode::Insert,
50 ModalState::Visual(_) | ModalState::Select(_) => BindingMode::Visual,
51 ModalState::OperatorPending => BindingMode::OperatorPending,
52 ModalState::Replace => BindingMode::Replace,
53 _ => BindingMode::Normal,
54 }
55}
56
57/// The layer-peeling walk behind a declined chord.
58///
59/// Hold one across the re-resolution loop: each [`peel`](Self::peel) removes
60/// the single layer that produced the declining binding and hands back the
61/// reduced set to re-translate against. The caller translates and applies;
62/// this decides what to translate against and when to stop.
63#[derive(Debug, Clone)]
64pub struct DeclinePeel {
65 binding_mode: BindingMode,
66 prefix: Vec<KeyChord>,
67 chord: KeyChord,
68 layers: Vec<ModeId>,
69}
70
71impl DeclinePeel {
72 /// `prefix` is the partial chord as it stood **before** the declined
73 /// dispatch — the sequence this chord completes, not what the dispatch
74 /// left behind.
75 pub fn new(
76 modal: ModalState,
77 prefix: Vec<KeyChord>,
78 chord: KeyChord,
79 layers: Vec<ModeId>,
80 ) -> Self {
81 Self {
82 binding_mode: binding_mode_for(modal),
83 prefix,
84 chord,
85 layers,
86 }
87 }
88
89 /// Drop the layer that produced the declining binding, and yield the
90 /// reduced layer set to re-translate against.
91 ///
92 /// `None` means there is nothing left to peel: the winning binding came
93 /// from the always-on Builtin / User layers, which cannot decline. The
94 /// walk is bounded — every pass removes exactly one layer.
95 pub fn peel(&mut self, keymap: &KeymapHandle) -> Option<&[ModeId]> {
96 let declining = crate::keymap_normal::binding_layer_mode(
97 keymap,
98 self.binding_mode,
99 &self.prefix,
100 &self.chord,
101 &self.layers,
102 )?;
103 self.layers.retain(|m| *m != declining);
104 Some(&self.layers)
105 }
106
107 /// The sequence this chord completes. Re-translation uses it, so a
108 /// declined multi-key chord re-resolves as the sequence it was.
109 pub fn prefix(&self) -> &[KeyChord] {
110 &self.prefix
111 }
112
113 pub fn chord(&self) -> KeyChord {
114 self.chord
115 }
116
117 pub fn layers(&self) -> &[ModeId] {
118 &self.layers
119 }
120
121 pub fn binding_mode(&self) -> BindingMode {
122 self.binding_mode
123 }
124
125 /// True when no mode layers remain, so a further decline has nowhere to
126 /// go and the caller should stop.
127 pub fn exhausted(&self) -> bool {
128 self.layers.is_empty()
129 }
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135
136 #[test]
137 fn every_modal_state_maps_to_the_keymap_it_resolves_in() {
138 assert_eq!(binding_mode_for(ModalState::Insert), BindingMode::Insert);
139 assert_eq!(binding_mode_for(ModalState::Replace), BindingMode::Replace);
140 assert_eq!(
141 binding_mode_for(ModalState::OperatorPending),
142 BindingMode::OperatorPending
143 );
144 assert_eq!(binding_mode_for(ModalState::Normal), BindingMode::Normal);
145 }
146
147 /// The prefix is what the chord COMPLETES, and it survives every pass —
148 /// re-resolving the trailing key alone is what turned a declined
149 /// `<leader>oJ` into vim's `J`.
150 #[test]
151 fn the_prefix_and_chord_are_held_across_the_walk() {
152 let prefix = vec![KeyChord::char('g')];
153 let peel = DeclinePeel::new(
154 ModalState::Normal,
155 prefix.clone(),
156 KeyChord::char('J'),
157 vec![ModeId::new("a-mode")],
158 );
159 assert_eq!(peel.prefix(), prefix.as_slice());
160 assert_eq!(peel.chord(), KeyChord::char('J'));
161 assert_eq!(peel.binding_mode(), BindingMode::Normal);
162 }
163
164 /// A walk with no layers is already finished — the caller must not spin.
165 #[test]
166 fn a_walk_with_no_layers_is_exhausted() {
167 let peel = DeclinePeel::new(
168 ModalState::Insert,
169 Vec::new(),
170 KeyChord::char('('),
171 Vec::new(),
172 );
173 assert!(peel.exhausted());
174 assert!(peel.layers().is_empty());
175 }
176}