input: Make the three input states one engine, keyed by kind - #2716
Open
huacnlee wants to merge 13 commits into
Open
input: Make the three input states one engine, keyed by kind#2716huacnlee wants to merge 13 commits into
huacnlee wants to merge 13 commits into
Conversation
`WindowExt::focused_input` returned `Option<Entity<InputState>>`, so after the Input/Textarea/Editor split there was no way to reach the focused state of anything but a single-line `Input`. Add `AnyInputState`, one enum covering every input state, and return it from `focused_input`. `Textarea` and `Editor` render through `Input::from_base` and were never registered at all, so `has_focused_input` was always `false` for them; all four kinds now register in their own `render`. `CompletionProvider` was the only LSP provider trait still taking `&mut Context<InputBaseState>` while the other five take `&mut App`. No implementation used the entity context, so it now matches the rest. Remove `OtpState::compat_input_state`, which existed only to feed the old registry. Fixes #2711 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
huacnlee
force-pushed
the
input-state-typestate
branch
3 times, most recently
from
August 14, 2026 20:19
e7430c9 to
cc2730b
Compare
`InputState`, `TextareaState` and `EditorState` were facades over
`InputBaseState`: each held an `Entity<InputBaseState>`, mirrored its value,
forwarded a subset of its methods, and applied builder options later in
`prepare`. That layer was written three times, leaked the engine through
`base_state`, and could not answer a reader without either a `cx` or a cached
copy that goes stale.
They are now aliases of the engine itself, separated by a mode marker:
pub type InputState = InputBaseState<InputMode>;
pub type TextareaState = InputBaseState<TextareaMode>;
pub type EditorState = InputBaseState<EditorMode>;
So `value()` reads `&self` — no `cx`, no copy, nothing to keep in step — and the
marker decides which methods exist. `step_by` is single-line only, `auto_grow`
and `rows` are multi-line only, and code actions and the LSP drive belong to the
editor. The marker set is sealed, since the engine branches on a closed set of
runtime layouts.
The marker also carries the state only its mode needs, through
`InputModeKind::Extras`, so a form full of text fields no longer carries an
editor's worth of machinery. `Lsp` with its three tasks, the decoration
collections, the inline completion, and the hover and context-menu state move
into `EditorExtras`:
InputState 2544 -> 1856 bytes (-27%)
TextareaState 2544 -> 2088 bytes (-18%)
EditorState 2544 -> 2776 bytes
Because that data is no longer on the engine, `lsp` becomes `EditorState::lsp`
and `lsp_mut` — a method that exists on the editor alone, where the old public
field was reachable from every input.
The engine's generic paths dispatch mode-specific work through `InputModeKind`:
the editor registers its own actions, drives its highlighter, and supplies the
decorations, semantic tokens and hover geometry the shared renderer paints. That
is also why the LSP seams need no back-reference: inside the editor's own
context, `cx.entity()` already is an `Entity<EditorState>`, so
`CodeActionProvider` and `InputHighlighter` take it directly.
`gpui-component` follows suit. `Input`, `SearchPanel` and the overlay registry
are generic over the mode; `OverlayMode` decides which overlays a mode shows and
reads its language-feature snapshot, so the popovers are built for the editor
alone. The inspector's editors, and the html/markdown/editor examples, now use
`EditorState`; `large-text` was `multi_line(true)` rather than a code editor, so
it becomes `TextareaState`; the go-to-line fields become `InputState`.
`InputBaseState` is no longer exported from `gpui_component::input`.
`validate` and `step_by` keep the `&mut App` closures the facades exposed, so
callers are unaffected. `prepare` stays as a no-op, since configuration now
applies as it is set.
Also fixes the WASM build, whose `syntect` highlighters implemented `update`
against a signature only `cfg(wasm)` ever compiled, and a `typos` failure in the
Collapsible story's fake API key.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
huacnlee
force-pushed
the
input-state-typestate
branch
from
August 14, 2026 21:04
cc2730b to
caa25ad
Compare
Splitting the constructor into one `new` per mode moved the shared defaults into `new_in_mode`, where `soft_wrap` was written as `false`. None of the three `new` functions set it back, so every Textarea and Editor stopped wrapping, while the doc comments still promised the old default. Set it back to `true` and pin it with a test, since the value now lives one level away from the constructors that a reader would check. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Twenty-one methods that only apply to one kind of input sat in the shared `impl` block, each opening with a `debug_assert!` on the runtime layout mode. `InputState::soft_wrap(false)` and `EditorState::masked(true)` compiled, then panicked in debug and passed silently in release. Move each one into the `impl` block of the mode it belongs to, so the compiler rejects it instead. The two multi-line modes share several of these, so add a sealed `MultiLineMode` marker for `TextareaMode` and `EditorMode` to bound one `impl` block on, rather than writing the methods twice. `toggle_masked` stays generic: the reveal button renders from the shared path, and the toggle can only be switched on through `InputState`. No call site needed changing, in the library, the stories, or the examples, which is the evidence that nothing relied on the old runtime check. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Overlay sync runs on every frame. It compared the popovers against
`format!("{:?}", …)` of their own content, so an editor with the
completion menu open re-serialised the whole item list — each entry with
its documentation — once per frame, per popover.
The snapshot it compared was just as expensive: it cloned the completion
and code-action item lists unconditionally, including on the early-out
path taken when no overlay is showing at all. Since `hide_context_menu`
only clears `open` and leaves the items in place, a closed menu kept
paying for a full clone of the list from the last time it opened.
Give the two menu states a revision the engine bumps when it swaps their
content, and key the popovers on cheap identity instead: the revision
for the menus, the anchor range for hover, `Rc` pointer equality for the
diagnostic. The snapshot now carries no content, and the item lists are
read only on the frames where they actually changed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two calls in the edit path were reaching for the wrong annotation hook.
On blur the engine called `reset_annotations`, which drops the hover
popover *and* clears every decoration. Blur used to drop only the hover
popover, so clicking away now threw out decorations the application had
installed and never asked to remove. `clear_hover_state` is the hook for
this — its own doc says "when the pointer leaves or focus moves".
The other call lost its body during the same refactor, leaving
if mask_changed {
} else {
M::adjust_annotations(..);
}
where the taken branch used to clear the decorations. Masking rewrites
the whole document, so ranges recorded against the old text point at
nothing; `reset_annotations` is what that branch wants. Harmless today,
since masking is single-line and a single-line input holds no
decorations, but it read as if it handled a case it dropped.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`LayoutMode` answered two different questions: how many rows to show and
how to grow them, and what kind of input this is. The second one already
had an answer at the type level, so the two could disagree — and did:
TextareaState::new(w, cx).auto_grow(1, 1)
// compile time: TextareaMode, every multi-line method reachable
// run time: is_multi_line() == false
because auto-grow derived multi-line from `max_rows > 1`. Soft wrap
could be set on that state and silently do nothing, and the debug
assertions guarding the multi-line methods fired on a configuration that
was perfectly legal.
Move the kind onto `InputModeKind` as `MULTI_LINE` and `CODE_EDITOR`
associated constants and answer from there. `LayoutMode` keeps the row
counts, the growth policy and the code-editor extras, and loses its
`multi_line` fields along with the three predicates derived from them.
Where the layout still needs the distinction — auto-grow, indenting,
indent guides — the caller passes it in.
Also moves `indent_guides` and `tab_size` into the mode blocks they
belong to; they were the same debug-assert pattern one file over, missed
by the earlier pass. No `debug_assert!` on the input mode is left.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`InputModeKind` had grown to 29 methods, 28 of which existed only for
the editor, and they were two different things wearing one name: places
where the engine hands control back mid-edit, and plain reads of fields
the renderer cannot reach because it is generic over the mode.
Split the reads out into `InputExtras`, implemented on the extras type
itself, so they are ordinary methods on ordinary data:
-M::decoration_layers(&state.extras)
+state.extras.decoration_layers()
`()` implements it with empty answers, which is what a plain input and a
textarea have to say. Adding a field an editor renders now touches only
this trait, and leaves the engine's callbacks alone.
Three methods had no callers at all — `lsp`, `lsp_mut` and
`hover_popover`, all shadowed by inherent methods on `EditorState` —
so they are gone.
`hover_definition_style` and `hover_definition_hitbox` were associated
functions on `TextElement<EditorMode>` that never touched the element,
reached through a hop from the engine's trait. They move onto
`InputBaseState<EditorMode>` where their data lives.
29 methods become 19 callbacks plus 6 accessors. The remaining 19 are
genuine re-entry points; removing those means the renderer no longer
being generic over the mode, which is a separate change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`EditorState::new` took a language ahead of `window` and `cx`, so the
three states were built three different ways and the editor's was the
odd one out. The language is a property of the editor like folding or
line numbers are, not something its construction depends on.
-EditorState::new("rust", window, cx)
+EditorState::new(window, cx).language("rust")
All three now read `new(window, cx)`, with the rest set through
builders. `LayoutMode::code_editor` loses its argument for the same
reason.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Input::focused_as` asked the caller to hand over the `AnyInputState` to register while the element holds focus. Every caller passed a value the type already determined — `Input<TextareaMode>` can only be `AnyInputState::Textarea` — and the field being an `Option` made "do not register at all" a reachable state that nothing wanted. Derive it instead, through the UI layer's existing per-mode seam, and drop the field, the builder and its three call sites. The `From` impls on `AnyInputState` are the mapping; `OverlayMode::any_state` is how the generic renderer reaches them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Input` was generic over the mode, and so were `Sizable`, `Selectable`,
`FocusableExt`, `Styled` and `RenderOnce` on it. Of the twelve things it
does to its state, every one is a method the shared engine offers for
every mode; only three places actually needed to know which mode it was
— the overlay registry, the focused-input identity, and handing the
state to the frame as a child.
Twelve generic call sites paid for by three. Hold a three-variant enum
instead and dispatch, and none of it needs a type parameter:
-pub struct Input<M: OverlayMode = InputMode> {
- state: Entity<InputBaseState<M>>,
+pub struct Input {
+ state: TextInputState,
`AnyInputState` could not serve, because its fourth variant is
`OtpState`, a different engine with none of these methods; forcing it in
would leave Otp as a no-op on half of them. `TextInputState` is the
narrower enum, with `AnyInputState` derived from it where the window
registry needs the wider one.
Three things follow. `Input` is one instantiation rather than three.
`OverlayMode`, `LspOverlays` and `LspSnapshot` are `pub(crate)`, since
nothing public is generic over the mode any more — which means
`InputBaseState` no longer appears in any public signature in
`crates/ui`, only in one `pub(crate) use`. And `OverlayMode::any_state`,
added a commit ago, is gone: the enum's own `From` impl replaced it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rivate `HighlighterUpdate` was `#[doc(hidden)] pub` while its only consumer, `LayoutMode::update_highlighter`, is `pub(crate)` and both modules on the path to it are private. It was never reachable from outside; the `pub` said otherwise. Now `pub(crate)`, and the `#[doc(hidden)]` that was hiding a name nobody could write goes with it. `InputBaseState` is the opposite case: its doc comment claimed the name was not part of the public API, and that is not true. The three states are type aliases of it, and an alias is only as usable as the type behind it — making it `pub(crate)` leaves `InputState` unable to do anything, which the compiler confirms with `private_interfaces` on every alias and accessor. Say what is actually true instead, and point readers at the aliases. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Forty-eight items across the input module were `pub` while sitting behind a private module path, so nothing outside the crate could name them. The `pub` claimed an API surface that did not exist, and reading the module gave no way to tell which items were genuinely public. Restrict them to `pub(crate)`, and put `#![warn(unreachable_pub)]` on the module so the next one is caught at the point it is written rather than by a later audit. The mechanical part is `cargo fix`'s work; the lint is what keeps it done. Two `#[doc(hidden)]` attributes go with them: once an item cannot be named from outside, hiding it from the docs is describing a name nobody can write. Four test helpers lose their `pub` for the same reason. Scoped to `input/`. The rest of the crate has about six more, left for a separate pass so this one stays reviewable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Replaces #2715, which took the facade route and could not get there, and
subsumes #2714 — its commit is included here, so that one can be closed.
InputState,TextareaStateandEditorStatewere facades overInputBaseState: each held anEntity<InputBaseState>, mirrored its value,forwarded a subset of its methods, and applied builder options later in
prepare. That layer was written three times, leaked the engine throughbase_state, and could not answer a reader without either acxor a cachedcopy that goes stale.
They are now aliases of the engine itself, separated by a mode marker:
value()reads&self— nocx, no second copy of the text, nothing to keep instep. One marker per state, so the vocabulary matches.
Net −493 lines.
Methods are gated by kind, not by assertion
implblockimpl<M>value,cursor,text,focus, editing, selectionimpl<M: MultiLineMode>soft_wrap,wrapping_indent,searchable,tab_sizeInputBaseState<InputMode>new(window, cx),masked,pattern,validate, number steppingInputBaseState<TextareaMode>new(window, cx),auto_grow,rowsInputBaseState<EditorMode>new(window, cx),language, folding, line numbers, code actions, the LSP driveInputStatehas noauto_groworrows; onlyEditorStateperforms codeactions or reaches an LSP. Not a
debug_assert— the method does not exist onthe type.
Twenty-five methods that used to open with
debug_assert!(self.mode.is_…())have moved into the block of the kind they belong to, so
InputState::soft_wrapand
EditorState::maskedare now compile errors rather than a debug panic and asilent no-op in release. No
debug_assert!on the input mode is left. The twomulti-line kinds share several of these, so a sealed
MultiLineModemarkerbounds one
implblock for both rather than the methods being written twice.No call site in the library, the stories or the examples needed changing, which
is the evidence that nothing relied on the runtime check.
The marker set is sealed: the engine branches on a closed set of runtime
layouts, so the three above are all of them.
The marker also carries the data
InputBaseStatehad 64 fields, and all three states were the same 2544 bytes: asingle-line field in a form still held an
Lspwith its three tasks, thedecoration collections, the inline completion, and the hover and context-menu
state. Those move into
InputModeKind::Extras, so each state carries only whatits mode uses:
InputStateTextareaStateEditorStateTwenty text fields in a form now save ~13.7KB, and what they save is exactly the
machinery they never use. The single-line payload (masking, validation, number
stepping) stays on the engine: it is ~120 bytes and its access sites sit inside
the shared edit path, so separating it would cost more in dispatch than it saves.
The mode hook, and why the LSP seams need no back-reference
The engine's edit and render paths are generic over the kind, so they cannot name
a specific state type.
InputModeKindis the seam: the editor registers its ownactions, drives its highlighter, and supplies the decorations, semantic tokens
and hover geometry the shared renderer paints. Inside those implementations
Selfis concrete. That is also what makes the LSP signatures fall out for free — in theeditor's own context
cx.entity()already is anEntity<EditorState>, soCodeActionProviderandInputHighlightersimply take it. #2715 needed a weakback-reference and a
HighlighterHostto reach the same place; both are gone.gpui-componentfollows suit, but only where it has to. Of the twelve thingsthe
Inputelement does to its state, every one is a method the engine offersfor every kind; only the overlay registry needs to know which kind it is. So
Inputholds a three-variant enum and dispatches, rather than carrying a typeparameter — one instantiation instead of three, and
OverlayMode,LspOverlaysand
LspSnapshotbecomepub(crate), since nothing public is generic over thekind.
InputBaseStateno longer appears in any public signature ingpui-component.Control → state
inspectorrust/json editorsEditorStateInputBaseStateexamples/html,examples/markdownEditorStateInputBaseStateexamples/editormain editorEditorStateInputBaseStateexamples/large-textbodyTextareaStateInputBaseStateInputStateInputBaseStatelarge-textwas built withmulti_line(true)rather than as a code editor, soit is a textarea.
Kept compatible on purpose
validateandstep_bykeep the&mut Appclosures the facades exposed, socallers are unaffected even though the engine's own context is now available.
preparestays as a no-op, since configuration applies as it is set — the callcan be deleted at leisure.
value(),cursor_position(),set_valueanddiagnostics_mut()are unchanged.Two regressions this refactor introduced, and fixed
Splitting the constructor per kind moved the shared defaults into a helper where
soft_wrapwas written asfalse, and none of the threenewfunctions set itback — every
TextareaandEditorhad stopped wrapping while the doc commentsstill promised the old default. Restored, and pinned with a test, since the value
now lives one level away from the constructors a reader would check.
Blur called
reset_annotations, which drops the hover popover and clears everydecoration, where it used to drop only the popover — so clicking away threw out
decorations the application had installed and never asked to remove.
clear_hover_stateis the hook for this; its own doc says "when the pointerleaves or focus moves".
Overlay sync no longer pays per frame
It ran every frame and compared the popovers against
format!("{:?}", …)oftheir own content, re-serialising the whole completion list — each entry with its
documentation — once per frame per popover. The snapshot it compared was just as
expensive: it cloned the completion and code-action item lists unconditionally,
including on the early-out path taken when no overlay is showing at all, and
since
hide_context_menuonly clearsopen, a closed menu kept paying for afull clone of the list from the last time it opened.
The menus now carry a revision the engine bumps when it swaps their content, and
the popovers are keyed on cheap identity instead: the revision for the menus, the
anchor range for hover,
Rcpointer equality for the diagnostic. The snapshotcarries no content, and the item lists are read only on the frames where they
actually changed.
Data and behavior, separated
InputModeKindhad grown to 29 methods, 28 of which existed only for the editor,and they were two different things under one name: points where the engine hands
control back mid-edit, and plain reads of fields the renderer cannot reach
because it is generic over the kind. The reads move to
InputExtras, implementedon the extras type itself, so they are ordinary methods on ordinary data:
Adding a field an editor renders now touches only that trait and leaves the
engine's callbacks alone. Three methods had no callers at all —
lsp,lsp_mutand
hover_popover, each shadowed by an inherent method onEditorState— andare gone. 29 methods become 19 callbacks plus 6 accessors.
Layout stops answering what kind of input this is
LayoutModeanswered two questions: how many rows to show and how to grow them,and what kind of input this is. The second already had an answer at the type
level, so the two could disagree — and did:
because auto-grow derived multi-line from
max_rows > 1. Soft wrap could be seton that state and silently do nothing, and the debug assertions guarding the
multi-line methods fired on a configuration that was perfectly legal. The kind
moves onto
InputModeKindasMULTI_LINEandCODE_EDITORassociatedconstants;
LayoutModekeeps the row counts and the code-editor extras andloses its
multi_linefields along with the three predicates derived from them.Also fixed
The WASM build:
syntect_highlighter.rsand theeditor_storyWASM branchimplement
InputHighlighter::updateagainst a signature that onlycfg(wasm)ever compiled, so the mismatch was never seen. And a
typosCI failure in theCollapsible story's fake API key.
Breaking Changes
InputBaseStateis no longer exported fromgpui_component::input. Use thestate of the control you are building; it is the same engine.
base_state()is gone: the state is the engine.lspis no longer a public field. It moved into the editor's payload, so it isreached through a method that exists on
EditorStatealone — where the fieldwas reachable from every input.
CodeActionProviderreceives anEntity<EditorState>.fn perform_code_action( &self, - state: Entity<InputBaseState>, + state: Entity<EditorState>, action: CodeAction, push_to_history: bool, window: &mut Window, cx: &mut App, ) -> Task<Result<()>>;EditorState::new, so all three states are built thesame way. It is a property like folding or line numbers.
WindowExt::focused_inputreturnsAnyInputState, which coversTextarea,EditorandOtpInputrather than onlyInput.where they used to compile and fire a debug assertion. Correct code is
unaffected.
InputHighlighter::updatetakes the editor's context.fn update( &mut self, edit: Option<InputEdit>, text: &Rope, folding: bool, window: &mut Window, - cx: &mut Context<InputBaseState>, + cx: &mut Context<EditorState>, );🤖 Generated with Claude Code