lattice_mode/modes/display.rs
1//! Display minor modes -- thin user-toggleable wrappers around
2//! the typed display options that already shipped (DESIGN.md
3//! §5.12 + mode-architecture.md §4.2.2 / M.7).
4//!
5//! Each mode contributes its corresponding typed option's
6//! "on" value via `Mode::options()`. Activating the mode flips
7//! the option in the buffer's resolved state via the
8//! mode-contribution layer; deactivating removes the
9//! contribution and the resolver falls back to the typed-option
10//! layer (TOML / `:set` / default).
11//!
12//! ## Two surfaces, one underlying state
13//!
14//! `:set number=true` and `:line-numbers-mode` both flip the
15//! resolved value of `Number` for the active buffer. They sit at
16//! different layers of the resolver:
17//!
18//! - **Typed-option layer** (`:set`): the persistent global /
19//! per-project value; user-typed config.
20//! - **Mode-contribution layer** (`:line-numbers-mode`): the
21//! per-buffer mode-driven override.
22//!
23//! Layer priority is `mode-contribution > typed-option`, so when
24//! the mode is active it wins regardless of `:set` state. The
25//! M.7.1 follow-up adds a cascade so `:set number=false` also
26//! deactivates `line-numbers-mode`, giving full bidirectional
27//! convergence; for v1 the mode is a one-way ratchet --
28//! activating it forces the on-value, deactivating it removes
29//! the contribution and the typed-option layer takes over.
30//!
31//! ## What's NOT here
32//!
33//! - `whitespace-show-mode` and `current-line-highlight-mode`:
34//! their backing typed options don't exist yet (the renderer
35//! has no `:set list` / `:set cursorline` plumbing). These
36//! land alongside the option in M.7.2.
37
38use lattice_config::OptionOverrideSet;
39
40use crate::{CapabilitySet, LifecycleFuture, Mode, ModeContext, ModeId, ModeKind};
41
42/// Macro: declare a display minor mode that contributes a
43/// single typed-option override when active.
44///
45/// `$option_path` is the option's type path (e.g.
46/// `lattice_config::Number`); `$on_value` is the literal value
47/// the mode contributes (typically `true` for booleans).
48macro_rules! display_minor_mode {
49 (
50 $struct_name:ident,
51 $mode_name:literal,
52 $(mirrors $mirrors_name:literal,)?
53 contributes $option_path:path = $on_value:expr,
54 $($extra_overrides:tt)*
55 ) => {
56 #[doc = concat!(
57 "`", $mode_name, "` — a display minor mode that overrides `",
58 stringify!($option_path), "` to `", stringify!($on_value),
59 "` (plus any further overrides) while active. `Manual` activation; ",
60 "a user toggle for the option, kept in sync with it by the host's ",
61 "option-mirror cascade when a `mirrors` name is declared."
62 )]
63 pub struct $struct_name;
64
65 impl $struct_name {
66 #[doc = concat!("The canonical id, `\"", $mode_name, "\"`.")]
67 pub fn mode_id() -> ModeId {
68 ModeId::new($mode_name)
69 }
70 }
71
72 impl Mode for $struct_name {
73 type Guard = ();
74 fn id(&self) -> ModeId {
75 Self::mode_id()
76 }
77 fn kind(&self) -> ModeKind {
78 ModeKind::Minor
79 }
80 fn options(&self) -> OptionOverrideSet {
81 lattice_config::overrides! {
82 $option_path = $on_value,
83 $($extra_overrides)*
84 }
85 }
86 fn required_capabilities(&self) -> CapabilitySet {
87 CapabilitySet::empty()
88 }
89 $(
90 fn mirrors_option(&self) -> Option<&'static str> {
91 Some($mirrors_name)
92 }
93 )?
94 fn on_activate(&self, _ctx: ModeContext) -> LifecycleFuture<'_, ()> {
95 Box::pin(async { Ok(()) })
96 }
97 }
98 };
99}
100
101display_minor_mode!(
102 LineNumbersMode,
103 "line-numbers-mode",
104 mirrors "number",
105 contributes lattice_config::Number = true,
106);
107
108// `relative-line-numbers-mode` mirrors vim's `:set rnu` cascade:
109// rnu implies nu (so the gutter renders at all). We contribute
110// both overrides directly. The `mirrors "relativenumber"` hint
111// keeps the mode's active state and the option's value in sync
112// via the host's option-mirror cascade -- the App's hardcoded
113// per-mode special case in `apply_option_cascade` is gone,
114// replaced with one declarative loop driven by this hint.
115display_minor_mode!(
116 RelativeLineNumbersMode,
117 "relative-line-numbers-mode",
118 mirrors "relativenumber",
119 contributes lattice_config::RelativeNumber = true,
120 lattice_config::Number = true,
121);
122
123display_minor_mode!(
124 WrapMode,
125 "wrap-mode",
126 mirrors "wrap",
127 contributes lattice_config::Wrap = true,
128);
129
130// `read-only-mode` -- user-toggleable surface for the same
131// `ReadOnly` option that `help-mode` / `file-tree-mode` /
132// LSP-log-mode contribute via M.3.1. Those majors are
133// kind-driven (every help buffer gets read-only); this minor
134// is the user gesture for "make THIS buffer read-only" on
135// arbitrary buffer kinds. `ReadOnly` is `customizable = false`
136// (mode-only); this is the only user-typed pathway. No
137// `mirrors` hint because `ReadOnly` has no `:set` surface.
138//
139// PD.4: written out rather than declared through
140// `display_minor_mode!` because it is the one mode here that is
141// not purely a display toggle -- it also declares an
142// **invocation runner**, and the option alone does not make a
143// buffer read-only. `ReadOnly` is read by the host's
144// `read_only_edit_rejected`, which guards `apply_edit_blocking`
145// and its batch peer -- the insert-mode char path, and only
146// that. Operators never pass through it: a `Document`'s grammar
147// dispatch applies its own edits and hands the host an
148// already-applied `Effect::Edits`. Contributing the option and
149// stopping there gated typing and left `x` / `dd` / `cw`
150// working, which is worse than not gating at all, because the
151// buffer looks protected and is not.
152/// `read-only-mode` — makes a buffer read-only in BOTH ways that matter:
153/// it contributes `ReadOnly = true` (which gates insert-mode typing) and
154/// declares an invocation runner that refuses operators (`x`, `dd`, `cw`,
155/// `p`), which never pass through the option's gate.
156///
157/// A user toggle on any buffer, and the mode a read-only major pulls in
158/// through [`Mode::implies`](crate::Mode::implies) — declaring `ReadOnly`
159/// alone leaves operators working.
160pub struct ReadOnlyMode;
161
162impl ReadOnlyMode {
163 /// The canonical id, `"read-only-mode"` — what [`Mode::id`](crate::Mode::id)
164 /// returns. Use it to name this mode without an instance (activation,
165 /// `implies`, keymap layers, tests).
166 pub fn mode_id() -> ModeId {
167 ModeId::new("read-only-mode")
168 }
169}
170
171impl Mode for ReadOnlyMode {
172 type Guard = ();
173 fn id(&self) -> ModeId {
174 Self::mode_id()
175 }
176 fn kind(&self) -> ModeKind {
177 ModeKind::Minor
178 }
179 fn options(&self) -> OptionOverrideSet {
180 lattice_config::overrides! {
181 lattice_config::ReadOnly = true,
182 }
183 }
184 fn required_capabilities(&self) -> CapabilitySet {
185 CapabilitySet::empty()
186 }
187 /// The host registers `Editor::run_read_only_motion` under this id
188 /// (see `editor_boot.rs`). Motions still move the cursor, `:` and
189 /// `/` still fall through to the central dispatcher, and mutating
190 /// operators echo "buffer is read-only" rather than silently doing
191 /// nothing -- a buffer that ignores keystrokes without saying so
192 /// reads as a hang.
193 fn invocation_runner(&self) -> Option<ModeId> {
194 Some(Self::mode_id())
195 }
196 fn on_activate(&self, _ctx: ModeContext) -> LifecycleFuture<'_, ()> {
197 Box::pin(async { Ok(()) })
198 }
199}
200
201// M.7.2: `whitespace-show-mode` -- backing for `:set list`
202// (vim convention). Renderer's whitespace-glyph plumbing
203// lands in M.7.3; today the mode + option exist as the
204// declarative + cascade surface, ready for the renderer hook.
205display_minor_mode!(
206 WhitespaceShowMode,
207 "whitespace-show-mode",
208 mirrors "whitespace",
209 contributes lattice_config::Whitespace = true,
210);
211
212// M.7.2: `current-line-highlight-mode` -- backing for
213// `:set cursorline`. Same M.7.3 deferral note as
214// whitespace-show-mode: option + mode declared today,
215// renderer's current-line-highlight pipeline lands later.
216display_minor_mode!(
217 CurrentLineHighlightMode,
218 "current-line-highlight-mode",
219 mirrors "current-line-highlight",
220 contributes lattice_config::CursorLine = true,
221);
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226
227 #[test]
228 fn each_display_mode_has_distinct_id() {
229 let ids = [
230 LineNumbersMode::mode_id(),
231 RelativeLineNumbersMode::mode_id(),
232 WrapMode::mode_id(),
233 ReadOnlyMode::mode_id(),
234 WhitespaceShowMode::mode_id(),
235 CurrentLineHighlightMode::mode_id(),
236 ];
237 for (i, a) in ids.iter().enumerate() {
238 for b in &ids[i + 1..] {
239 assert_ne!(a, b);
240 }
241 }
242 }
243
244 #[test]
245 fn each_display_mode_is_minor_with_no_caps() {
246 use crate::DynMode;
247 let modes: Vec<&dyn DynMode> = vec![
248 &LineNumbersMode,
249 &RelativeLineNumbersMode,
250 &WrapMode,
251 &ReadOnlyMode,
252 &WhitespaceShowMode,
253 &CurrentLineHighlightMode,
254 ];
255 for m in modes {
256 assert_eq!(m.kind(), ModeKind::Minor, "{} not minor", m.id());
257 assert_eq!(
258 m.required_capabilities(),
259 CapabilitySet::empty(),
260 "{} declared caps",
261 m.id(),
262 );
263 }
264 }
265
266 #[test]
267 fn line_numbers_mode_contributes_number_true() {
268 let opts = <LineNumbersMode as Mode>::options(&LineNumbersMode);
269 // Single contribution.
270 assert_eq!(opts.iter().count(), 1);
271 }
272
273 #[test]
274 fn relative_line_numbers_mode_contributes_both_options() {
275 // Mirrors vim's `:set rnu` ⇒ `:set nu` cascade by
276 // contributing both directly. Lets a user have
277 // `relative-line-numbers-mode` active without needing
278 // `line-numbers-mode` separately.
279 let opts = <RelativeLineNumbersMode as Mode>::options(&RelativeLineNumbersMode);
280 assert_eq!(opts.iter().count(), 2, "expected RelativeNumber + Number",);
281 }
282
283 #[test]
284 fn wrap_mode_contributes_wrap_true() {
285 let opts = <WrapMode as Mode>::options(&WrapMode);
286 assert_eq!(opts.iter().count(), 1);
287 }
288
289 #[test]
290 fn read_only_mode_contributes_read_only_true() {
291 let opts = <ReadOnlyMode as Mode>::options(&ReadOnlyMode);
292 assert_eq!(opts.iter().count(), 1);
293 }
294
295 #[test]
296 fn whitespace_show_mode_contributes_whitespace_true() {
297 let opts = <WhitespaceShowMode as Mode>::options(&WhitespaceShowMode);
298 assert_eq!(opts.iter().count(), 1);
299 }
300
301 #[test]
302 fn current_line_highlight_mode_contributes_cursorline_true() {
303 let opts = <CurrentLineHighlightMode as Mode>::options(&CurrentLineHighlightMode);
304 assert_eq!(opts.iter().count(), 1);
305 }
306}