pub trait Mode:
Send
+ Sync
+ 'static {
type Guard: Send + 'static;
Show 22 methods
// Required methods
fn id(&self) -> ModeId;
fn kind(&self) -> ModeKind;
fn on_activate(&self, ctx: ModeContext) -> LifecycleFuture<'_, Self::Guard>;
// Provided methods
fn target_buffer_kind(&self) -> Option<BufferKind> { ... }
fn target_language(&self) -> Option<&str> { ... }
fn options(&self) -> OptionOverrideSet { ... }
fn keymap(&self) -> Keymap { ... }
fn decorations(&self) -> Vec<DecorationProvider> { ... }
fn gutter_decorations(
&self,
_ctx: &DecorationCtx<'_>,
) -> Vec<GutterDecoration> { ... }
fn completion_sources(&self) -> Vec<CompletionSourceContribution> { ... }
fn action_handlers(&self) -> Vec<ActionHandlerContribution> { ... }
fn required_capabilities(&self) -> CapabilitySet { ... }
fn conflicts_with(&self) -> &[ModeId] { ... }
fn implies(&self) -> &[ModeId] { ... }
fn presents_extensions(&self) -> &[&'static str] { ... }
fn mirrors_option(&self) -> Option<&'static str> { ... }
fn invocation_runner(&self) -> Option<ModeId> { ... }
fn refresh_action(&self) -> Option<&'static str> { ... }
fn fold_toggle_action(&self) -> Option<&'static str> { ... }
fn refresh_on_open(&self) -> bool { ... }
fn activation_policy(&self) -> ActivationPolicy { ... }
fn editable_tail(&self) -> Option<EditableTail> { ... }
}Expand description
Declarative mode contract.
Per mode-architecture.md §5.2 + §7.1, this trait splits into three concerns:
- Declarative methods (
options,keymap,subscriptions,decorations,required_capabilities,conflicts_with,implies,completion_sources,mirrors_option) return read-only data. The registry applies these to the layer stack on activation and removes them on deactivation. The mode can never leak contributions past its lifetime by construction. - Lifecycle hook (
Mode::on_activate) returns an ownedGuardvalue carrying every resource the mode allocated (subscription IDs, prior option values to restore, supervisor handles, etc.). The dispatcher stashes the Guard in aGuardStorekeyed by(BufferId, ModeId). - Deactivation cleanup. There is no
on_deactivate. On deactivation the dispatcher drops the stashed Guard; the Guard’sDropimpl performs every cleanup action. This makes cleanup mandatory (compiler-enforced via Rust ownership), bug-resistant (a forgotten cleanup step becomes a compile-time leak rather than a runtime resource leak), and uniform (marker modes use()as Guard).
Validated against Zed’s Subscription / Task<T> cancel-on-
drop pattern and helix’s Rust-ownership-based cleanup; see
mode-architecture.md §7.1.
Send + Sync + 'static so a single trait object can be shared
across threads (the registry runs on whatever task drives
activation; subscribers can be on any task).
§Lifecycle, in order
- Registration —
ModeRegistry::register(native, enabled) orregister_available(plugin, disabled until the user enables it). The registry readsid,kind,target_buffer_kind,target_languageandpresents_extensionsonce, to build its indexes. The host separately walks every registered mode once at boot to translatekeymapinto aKeymapLayer::MinorMode(id)/MajorMode(id)layer and to registeraction_handlers. - Activation — per buffer, on the editor actor.
activate_major/activate_minorvalidates (registered, right kind,required_capabilities,conflicts_with,impliesregistered), records the mode and its implied cascade inActiveModessynchronously, then runs each step’son_activatein DFS order. The cascade future is polled once inline: a hook that never awaits completes before the activate call returns; the firstPendingmoves the rest onto the runtime. Success publishesMajorEntered/MinorActivated; failure publishes aModeEvent::ModeActivationFailedand the host rolls the cascade back. - While active — the host reads the declarative methods
(
options,completion_sources,gutter_decorationsper frame,editable_tailper edit, …). The keymap layer is scoped to buffers where the mode is active by a per-keystroke filter. - Deactivation — synchronous: the lifecycle event publishes, then the stashed Guard is dropped. Implied minors cascade-deactivate.
§What an implementor must not do
- Keep per-buffer state on
self. One instance serves every buffer; per-activation state goes in the Guard. - Block in
on_activate: it may run inline on the editor actor. Do I/O with.awaitor hand it to a spawned task. - Make the declarative methods impure. Most are read once; returning a different answer later is not observed consistently.
- Bind feature chords at
KeymapLayer::Builtinor put handler bodies in the host. The mode owns its chords (keymap) and the bodies (action_handlersor handlers registered inon_activate).
§Examples
A minimal minor mode with an owned Guard, driven through registration, activation and deactivation exactly as the host drives it:
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use lattice_config::ConfigRegistry;
use lattice_mode::{
ActivationPolicy, ActiveModes, GuardStoreHandle, LifecycleFuture, Mode, ModeContext,
ModeId, ModeKind, ModeRegistry, ServiceRegistry,
};
use lattice_protocol::BufferId;
use lattice_runtime::EventBus;
/// Counts live activations; a real Guard would hold a `Subscription`,
/// a restored option value, a supervisor handle, …
struct CountGuard(Arc<AtomicUsize>);
impl Drop for CountGuard {
fn drop(&mut self) {
self.0.fetch_sub(1, Ordering::SeqCst);
}
}
struct TrailingSpaceMode {
live: Arc<AtomicUsize>,
}
impl Mode for TrailingSpaceMode {
type Guard = CountGuard;
fn id(&self) -> ModeId {
ModeId::new("trailing-space-mode") // must end in `-mode`
}
fn kind(&self) -> ModeKind {
ModeKind::Minor
}
fn activation_policy(&self) -> ActivationPolicy {
ActivationPolicy::Global // every document buffer
}
fn on_activate(&self, ctx: ModeContext) -> LifecycleFuture<'_, CountGuard> {
let live = self.live.clone();
Box::pin(async move {
let _ = ctx.buffer_id(); // per-buffer state goes in the Guard
live.fetch_add(1, Ordering::SeqCst);
Ok(CountGuard(live))
})
}
}
let live = Arc::new(AtomicUsize::new(0));
let mut registry = ModeRegistry::new();
let id = registry.register(TrailingSpaceMode { live: live.clone() }).unwrap();
// What the host holds per editor, and per buffer.
let (guards, events) = (GuardStoreHandle::new(), Arc::new(EventBus::new()));
let (config, services) = (Arc::new(ConfigRegistry::new()), Arc::new(ServiceRegistry::new()));
let buffer = BufferId::new(1);
let mut active = ActiveModes::new();
registry
.activate_minor(&mut active, &guards, &config, &events, &services, buffer, id, Default::default())
.unwrap();
// The hook never awaited, so it completed inline and its Guard is stashed.
assert!(active.has_minor(id));
assert!(guards.contains(buffer, id));
assert_eq!(live.load(Ordering::SeqCst), 1);
// Deactivation drops the Guard: cleanup is the Guard's `Drop`.
registry.deactivate_minor(&mut active, &guards, &events, buffer, id).unwrap();
assert!(!active.has_minor(id));
assert_eq!(live.load(Ordering::SeqCst), 0);Required Associated Types§
Sourcetype Guard: Send + 'static
type Guard: Send + 'static
Owned cleanup token returned by Self::on_activate.
The mode allocates whatever resources it needs (event
subscriptions, supervisor handles, prior option values
to restore) and packages them in a Guard struct with a
Drop impl that performs cleanup. Marker modes that
have no cleanup work use ().
Send + 'static so the dispatcher can stash the Guard
in a typed-erased Box<dyn Any + Send> and move it
across threads if needed.
Required Methods§
Sourcefn id(&self) -> ModeId
fn id(&self) -> ModeId
Canonical identity. Same value every call.
Must end in -mode: ModeRegistry::register
refuses anything else with
RegistrationError::MissingModeSuffix.
It is also the name users and plugins refer to the mode by
(:describe-mode <id>, a plugin’s enable-mode(<id>), the
<id>.activation config key) and the key of the mode’s keymap layer, so
changing it is a breaking change. Convention: expose it as an
associated fn mode_id() -> ModeId so other code can name the mode
without an instance.
Sourcefn kind(&self) -> ModeKind
fn kind(&self) -> ModeKind
Major or minor. Read at registration and on every activation
call (activate_major on a minor, or vice versa, is
ModeActivationError::WrongKind).
Sourcefn on_activate(&self, ctx: ModeContext) -> LifecycleFuture<'_, Self::Guard>
fn on_activate(&self, ctx: ModeContext) -> LifecycleFuture<'_, Self::Guard>
Lifecycle. Called once per (buffer, activation) cycle
after the registry has applied the declarative
contributions. Returns an owned Guard
carrying every resource the mode allocated. The
dispatcher stashes the Guard until deactivation, at which
point dropping it performs cleanup via the Guard’s Drop
impl.
Marker modes whose Guard = () typically write:
type Guard = ();
fn on_activate(&self, _ctx: ModeContext) -> LifecycleFuture<'_, ()> {
Box::pin(async { Ok(()) })
}Where it runs. On the editor actor, polled once inline; if it
returns Pending the remainder continues as a runtime task. Awaiting
real I/O is therefore fine; blocking is not. Within one cascade,
steps run strictly in order, so an implied child’s hook never
observes its parent’s half-built state.
Late results. If the mode is deactivated (or re-activated)
while this future is still pending, the Guard it eventually returns
is dropped immediately instead of stashed — so the Guard’s Drop
must be correct even for an activation nobody observed.
What ctx gives you: the buffer id, typed services
(ModeContext::service), the config registry and the event bus —
not the buffer’s text. A mode that needs to create or fill a
synthetic buffer reaches the host through a service
(BufferStoreHandle,
ModeActivator); asynchronous results must
reach the screen through an inbound channel
(inbound), which wakes the editor, not a bare
tick callback.
Stateful modes return a Guard struct whose Drop impl
performs cleanup (unsubscribe, restore prior option,
drop supervisor handle, etc.).
Errors propagate as ModeActivationError; do not panic.
Idempotent setup contract: on_activate may run more
than once in a buffer’s lifetime (each preceded by a
Guard-drop if previously active). Implementations must
produce a fresh Guard every time.
Provided Methods§
Sourcefn target_buffer_kind(&self) -> Option<BufferKind>
fn target_buffer_kind(&self) -> Option<BufferKind>
For major modes, the [BufferKind] this mode is the default
major for (H.2, 2026-05-31). ModeRegistry::register
indexes this so
ModeRegistry::find_major_for_kind can
dispatch buffer-creation events to the right major without
host-side match BufferKind { ... } blocks.
Returns None for:
- All minor modes.
- Major modes that don’t bind to a [
BufferKind] directly (e.g. language majors likerust-mode/markdown-modeon plain Documents — they activate viaLangdetection on [BufferKind::Document], not via kind dispatch).
One [BufferKind] is owned by at most one major; the
registry treats the first registration as authoritative
and warns on subsequent claims (clobbering is a
developer bug, not an extensibility seam).
Note: a single major may be referenced by both a kind and
a Lang (e.g. markdown-mode is the major for
[BufferKind::Help] and also the language major for
[BufferKind::Document] + Lang::Markdown). Declaring
target_buffer_kind = Some(Help) does not exclude the
Lang-detected dispatch path — they cohabit.
Sourcefn target_language(&self) -> Option<&str>
fn target_language(&self) -> Option<&str>
For major modes, the language this mode is the
default major for, by canonical name (Lang::name() —
"rust", "org") (OM.1). The peer of
target_buffer_kind for the
dispatch path [BufferKind::Document] takes: language
detection rather than kind dispatch.
ModeRegistry::register indexes this so
ModeRegistry::find_major_for_lang can resolve a
document’s major without a host-side match Lang { ... },
which is what makes a plugin-contributed language’s
major possible at all: Lang::Plugin(_) has no arm in the
host’s hand-written table and never will, because the host
does not know the language exists until a plugin says so.
Returns None for:
- All minor modes. A minor declaring one is ignored at register-time rather than indexed — resolving a minor as a buffer’s major would corrupt activation.
- Major modes not bound to a language (kind-bound majors
like
file-tree-mode, or manual-only majors).
One language is owned by at most one major; first
registration wins and later claims warn, matching
target_buffer_kind.
The built-in language majors (rust-mode, markdown-mode,
…) do not declare this yet — they resolve through
lattice_syntax::major_mode_id_for_lang’s table, which is
consulted first. Migrating them onto this index would
collapse that table, and is deliberately left as separate
work: this slice makes plugin languages reachable, it does
not rewrite how the built-ins resolve.
Sourcefn options(&self) -> OptionOverrideSet
fn options(&self) -> OptionOverrideSet
Option overrides this mode contributes. Pure declarative
(same return value every call); the host merges these
into the buffer’s option-resolution layer stack while the mode
is active, at the mode’s layer (later-activated minors win ties),
and drops them on deactivation. Build with
lattice_config::overrides! { lattice_config::Wrap = true, … }.
Sourcefn keymap(&self) -> Keymap
fn keymap(&self) -> Keymap
Keymap chord → command additions / overrides.
Read once when the mode is registered; the host translates every
binding and entry into the layer KeymapLayer::MinorMode(id) (or
MajorMode(id)), and a per-keystroke filter makes that layer live
only in buffers where this mode is active. A mode cannot place a
binding in any other layer. Table-form entries name commands by
string and are resolved against the command registry at that point
(an unknown name is logged and skipped).
§Examples
use std::sync::LazyLock;
use lattice_mode::{
keymap_entry, Keymap, KeymapEntry, LifecycleFuture, Mode, ModeContext, ModeId,
ModeKind,
};
static PREVIEW_KEYS: LazyLock<Vec<KeymapEntry>> = LazyLock::new(|| {
vec![keymap_entry! { mode: Normal, chord: "q", doc: "Close the preview", cmd: "preview:close" }]
});
struct PreviewMode;
impl Mode for PreviewMode {
type Guard = ();
fn id(&self) -> ModeId { ModeId::new("preview-mode") }
fn kind(&self) -> ModeKind { ModeKind::Minor }
fn keymap(&self) -> Keymap { Keymap::from_entries(PREVIEW_KEYS.as_slice()) }
fn on_activate(&self, _ctx: ModeContext) -> LifecycleFuture<'_, ()> {
Box::pin(async { Ok(()) })
}
}
let km = PreviewMode.keymap();
assert_eq!(km.entries.len(), 1);
assert_eq!(km.entries[0].doc, "Close the preview");Sourcefn decorations(&self) -> Vec<DecorationProvider>
fn decorations(&self) -> Vec<DecorationProvider>
Decoration providers (gutter / inline / overlay / statusline). Stub — reserved for the WIT plugin path (M.10).
Sourcefn gutter_decorations(&self, _ctx: &DecorationCtx<'_>) -> Vec<GutterDecoration>
fn gutter_decorations(&self, _ctx: &DecorationCtx<'_>) -> Vec<GutterDecoration>
Gutter sign decorations this mode contributes while active.
Called once per pane per frame with a DecorationCtx
carrying relevant render-state snapshots (diff sign map, LSP
diagnostics arc). Returns per-line GutterDecoration values;
the renderer partitions them by variant into the appropriate
gutter column. Default: empty (no contribution).
Sourcefn completion_sources(&self) -> Vec<CompletionSourceContribution>
fn completion_sources(&self) -> Vec<CompletionSourceContribution>
Insert-mode completion sources this mode contributes while
active on a buffer. Empty by default; minors that own a
completion source (lsp-completion-mode,
snippet-completion-mode, buffer-words-mode,
tree-sitter-completion-mode, path-completion-mode,
plugin sources) override.
Sourcefn action_handlers(&self) -> Vec<ActionHandlerContribution>
fn action_handlers(&self) -> Vec<ActionHandlerContribution>
Global (buffer-agnostic) action handlers this
mode contributes (SN.3c.0). The host walks every registered mode’s
action_handlers() once at boot, resolves each
action_name → CommandId, registers the handler in the
ActionHandlerRegistry, and holds the tokens for the app’s
lifetime. Use this for handlers that read the active
buffer / cursor / services from the ActionContext at call
time and close over no per-buffer state (e.g. snippet
expand). Per-buffer, session-scoped handlers register in
on_activate instead, so their tokens
drop with the Guard. Default: none. See
feedback_effect_vocabulary_is_host_boundary.
Sourcefn required_capabilities(&self) -> CapabilitySet
fn required_capabilities(&self) -> CapabilitySet
Capabilities the mode requires. Validated at activation;
missing capability ⇒
ModeActivationError::MissingCapability, never silent
skip.
Sourcefn conflicts_with(&self) -> &[ModeId]
fn conflicts_with(&self) -> &[ModeId]
Modes this one cannot be active alongside.
Checked symmetrically when a minor is activated (directly or
through an implies cascade): activation fails with
ModeActivationError::Conflict if any mode listed here is active,
or if the active major or any active minor lists this mode. Nothing
is auto-deactivated — the caller decides whether to deactivate the
other mode and retry. activate_major does not consult this list.
Sourcefn implies(&self) -> &[ModeId]
fn implies(&self) -> &[ModeId]
Minor modes activating this mode also activates.
Used by relative-line-numbers-mode ⇒ line-numbers-mode, and by
read-only majors ⇒ read-only-mode.
Every id must be registered, or activation fails with
ModeActivationError::UnregisteredDependency. The whole tree is
validated and recorded before any hook runs; hooks then run parent
first, depth-first. Deactivating a minor cascade-deactivates the
minors it implied (deactivating a major does not).
Sourcefn presents_extensions(&self) -> &[&'static str]
fn presents_extensions(&self) -> &[&'static str]
File extensions (lowercase, no dot) this major PRESENTS rather than edits — the file is never loaded as text.
The open path consults this before reading: a match builds a
placeholder buffer with the real path and no content, and this major
renders the file some other way. image-mode shows it as a media
block; a future PDF or archive viewer is the same shape.
Default empty, which is every ordinary major: its buffer IS the file’s text.
A mode declaring this must also be read-only in both of the ways
that matter — ReadOnly = true in options AND
read-only-mode in implies — because the buffer’s
text is a placeholder and saving it would overwrite the real file with
nothing. The option alone gates typing; the implied mode is what
refuses the operators.
Sourcefn mirrors_option(&self) -> Option<&'static str>
fn mirrors_option(&self) -> Option<&'static str>
Declarative mirror hint for “this mode is the on/off
switch for a typed option of the same observable state”.
Some(canonical_name) ⇒ a host-driven cascade keeps the
mode’s active state and the option’s value in sync.
Sourcefn invocation_runner(&self) -> Option<ModeId>
fn invocation_runner(&self) -> Option<ModeId>
Invocation-runner discovery (2026-05-26). Modes that own
command-invocation dispatch for their buffer kind
(terminal-mode, oil-mode, file-tree-mode, help-mode, …)
return their canonical ModeId; the host registers a
runner function under that id at boot, and
Editor::run_invocation looks it up by walking the
active modes on the active pane’s buffer (minors first,
then major) before falling back to the central grammar
Action gate.
Returning None (the default) means the mode doesn’t
claim invocation dispatch — the keymap / decorations /
completion-source contributions still apply.
Replaces the hardcoded match BufferKind block that
previously lived in Editor::run_invocation. Plugin-
installed modes for plugin-installed buffer kinds now
extend the dispatcher without touching host code.
Sourcefn refresh_action(&self) -> Option<&'static str>
fn refresh_action(&self) -> Option<&'static str>
Which of this mode’s own actions refreshes
its view, or None (the default) when the mode backs nothing
refreshable (RV.1, 2026-08-10).
gr means “refresh this view” in every synthetic buffer. That
is a property of synthetic views as a class, so the chord lives
once on RefreshableViewMode —
not re-declared per mode. Before RV.1 it was re-declared per
mode, and the two views that landed most recently (*problems*,
narrow) had no gr at all: a gap in a copied set does not
announce itself.
This declares a target, not a body. The handler stays exactly
where action_handlers puts it — a mode
returning Some("action:magit-refresh") keeps the closure it
already registered under that name. Declaring a target rather
than doing the work is the same shape
invocation_runner and
mirrors_option already have; a
refresh(&self, ctx) doing the work would give modes two ways to
express one body.
Returning Some also auto-activates refreshable-view-mode
through the implies cascade, so a mode author writes one line and
gets the chord.
The host resolves this by walking the buffer’s active modes
(minors most-recently-activated first, then major) — see
Editor::resolve_refresh_action. When no active mode declares
one, the chord echoes nothing to refresh here rather than being
swallowed, so the absence is spoken.
See docs/dev/architecture/mode-architecture.md §5.5.
Sourcefn fold_toggle_action(&self) -> Option<&'static str>
fn fold_toggle_action(&self) -> Option<&'static str>
Which of this mode’s actions <Tab> fires, and the declaration that
pulls in
foldable-view-mode —
the shared minor owning the chord.
Some(FOLD_TOGGLE_DEFAULT_ACTION) for the ordinary “cycle the fold at
the cursor”; Some(own_action) for a view with a real specialisation
(magit expands a diff on the first press over a status file line).
None — the default — means this mode’s buffers keep <Tab> as
jump-list-forward, which is what every ordinary document wants.
Sourcefn refresh_on_open(&self) -> bool
fn refresh_on_open(&self) -> bool
Should re-opening this view re-run its refresh?
A synthetic buffer is created once and reused: the host’s
ensure_named_synthetic_document returns the existing buffer by
name, so a mode’s on_activate — which is what fills the buffer
— runs on the FIRST open only. For a view whose content is a
snapshot of external state, that makes every later open a time
capsule: C-x g on an already-open *magit:status* showed the
repository as it was when the buffer was first created, with
nothing on screen saying so.
Returning true makes the host dispatch this mode’s declared
refresh_action after an open that
reused an existing buffer. First opens are untouched:
on_activate has just built the content, and refreshing again
would be a second scan for the same answer.
Opt-in, and only for content derived from outside the editor.
A view whose content is authored in the editor (a help page, a
transcript, *messages*) has nothing to re-derive, and refreshing
it would discard scroll position for no gain.
The contract on the body: a refresh reached this way must be
self-contained — spawn its own work and return no Effect. This
path has no dispatch outcome to route renderer-coupled effects
through (OpenBuffer, OpenPicker, …), so a returned effect is
logged as a wiring error rather than half-applied. Magit’s
refresh satisfies this: it spawns the git work and returns
None.
Declaring true without a refresh_action does nothing; the two
are read together.
Sourcefn activation_policy(&self) -> ActivationPolicy
fn activation_policy(&self) -> ActivationPolicy
A minor mode’s default auto-activation policy
(MA.1; mode-architecture.md §7.4). The host’s minor-activation
resolver reads this for every registered minor when a buffer
enters a major mode, and activates those whose policy
admits the entered major. The
default is ActivationPolicy::Manual — auto-activate
nowhere until the mode or the user opts in. Ignored for major
modes (a buffer’s major is chosen by the major resolver, not
this allowlist).
Sourcefn editable_tail(&self) -> Option<EditableTail>
fn editable_tail(&self) -> Option<EditableTail>
The mode’s editable tail on an otherwise read-only buffer, or
None (the default) for a fully read-only / fully writable buffer
(AU‑3).
A mode backing an owner-written buffer (the agent conversation,
future REPL / scratch buffers) declares a tail so the host’s
read-only edit gate lets user keystrokes edit only the trailing
prompt region — the comint pattern. Consulted directly by the gate
(no per-buffer seeding): the tail is expressed relative to the buffer
end (see EditableTail), so it stays valid as the owner appends
content above it. Returning None leaves the read-only gate’s
behaviour unchanged (edits rejected iff ReadOnly is resolved true).
Dyn Compatibility§
This trait is dyn compatible.
In older versions of Rust, dyn compatibility was called "object safety".