Skip to content

input: Offer a List under the field, like <input list> - #3785

Open
bhavishyasharma wants to merge 6 commits into
longbridge:mainfrom
bhavishyasharma:claude/combobox-free-text
Open

bhavishyasharma wants to merge 6 commits into
longbridge:mainfrom
bhavishyasharma:claude/combobox-free-text

Conversation

@bhavishyasharma

@bhavishyasharma bhavishyasharma commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Description

Combobox's value is always one of its items, so a field that takes any text but offers known values, like HTML's <input list>, had to be composed by hand.

Input::list shows a List under the field while the user types. The app writes a normal ListDelegate, which owns matching, debouncing, cancellation (replacing the task from perform_search drops the old one), sections, paging and rows. There is no new state type, item trait or search builder, and a fixed list is a delegate that filters synchronously.

  • Nothing is highlighted until Down. Enter with nothing highlighted emits InputEvent::PressEnter, so any text stays valid.
  • Up from the first row returns to the typed text. Enter accepts the highlighted row, and Escape closes the popup.
  • The delegate's confirm writes the accepted value, which may differ from the row's label. InputState::replace_all keeps it one undoable edit, and that change starts no search.
  • The popup shows only while the input has focus and the delegate has rows or reports loading. The list never takes focus.

AI-generated: written with AI assistance.

Public API

gpui-component

  • Input::list<D: ListDelegate>(self, list: &Entity<ListState<D>>) -> Self — offer this list under the field while the user types, like an HTML <input list>; the input still accepts any text.

ListState and ListDelegate gain no public items. The additions to ListState are pub(crate).

How to Test

  • cargo test -p gpui-component --lib --features test-support list_popup
  • cargo run -- input, section List: type cpu, press Down and Enter; type text that matches nothing and press Enter.

Not verified

  • Clicking a row and clicking outside were run only in GPUI tests, not in a real window. Keyboard flow was run on Linux under Hyprland.
  • macOS, Windows, IME composition and typos were not run.
  • Writing the value from a ListEvent::Confirm subscription is documented as unsupported from reading the event order. No test covers it.

🤖 Noop (reviewer) · AI agent acting for @huacnlee

Combobox's value is always one of its items, so a field that takes any
text but offers known values — HTML's <datalist> — had to be composed by
hand, as the Completion story does, and usually ended up with its menu
beside the field rather than under it.

input::Autocomplete wraps the application's InputState and suggests values
from a list. Suggestions open under the field as the user types: those
containing the text, ignoring case, prefix matches first and each group in
the given order, never the text already typed. Down and Up move through
them, Enter accepts the highlighted one, Escape closes them. With nothing
highlighted Enter reaches the input, so InputEvent::PressEnter still
submits the typed text. Accepting replaces the text in one undoable edit,
which emits InputEvent::Change; no new event types. Pressing a suggestion
keeps focus in the field.

It lives in the input module beside NumberInput and OtpInput. Adds a
section to the Input story and en/zh-CN docs.

This change was written with AI assistance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PUrcY5FvmxAKCbZAmSUFJ9

@huacnlee huacnlee left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think Autocomplete element is a good API design.

Please references to HTML, Shadcn to design the new API.

Review asked for an API modelled on HTML and shadcn/ui rather than a
separate Autocomplete element. In HTML, suggestions are not a widget
wrapped around the field: they are the input's `list` attribute naming a
<datalist> of options, and shadcn/ui has no free-text autocomplete beyond
the native input. So the Autocomplete type is removed and the behavior
becomes an option of Input:

    Input::new(&metric).suggestions(["cpu.load", "memory.used"])

The list now attaches inside the input's own root, which is already
positioned and already hosts floating overlays, instead of a wrapper div.
It is therefore always exactly as wide as the field, whatever width the
caller gives the Input; the wrapper could stretch wider than the field.
Its menu-height option is dropped, as HTML has none.

Behavior is unchanged: suggestions containing the text, prefix matches
first; Down/Up/Enter/Escape; Enter with nothing highlighted still emits
PressEnter; accepting is one undoable edit emitting Change. A disabled or
read-only input now offers no suggestions, which a test covers.

This change was written with AI assistance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PUrcY5FvmxAKCbZAmSUFJ9
@bhavishyasharma

Copy link
Copy Markdown
Contributor Author

Thanks — reworked in d874977 following HTML and shadcn/ui.

HTML doesn't model this as a separate element: suggestions are the input's list attribute pointing at a <datalist>. shadcn/ui likewise has no free-text autocomplete component — its Combobox is for choosing one of a list. So the Autocomplete type is gone and suggestions are an option on Input:

Input::new(&metric).suggestions(["cpu.load", "cpu.temperature", "memory.used"])

@bhavishyasharma bhavishyasharma changed the title input: Add Autocomplete, free text with suggestions input: Offer suggestions as an Input option, like <input list> Nov 4, 2026
@huacnlee

huacnlee commented Nov 4, 2026

Copy link
Copy Markdown
Member

Review of d8749773, which reworks the API after the requested changes. This is a QA and API review only; the maintainer's change request stays with the maintainer.

Verdict: the new API shape looks right to me. I found three behavior problems in the popup, and the PR title and description still describe the removed API.

API and naming

  • Input::new(&state).suggestions([...]) maps directly to HTML's <input list> with <datalist>: a field option, free text kept, no new element. The Autocomplete type and its menu_max_h / input builders are gone, and suggestions is a private module.
  • shadcn/ui lists Input, Input Group, Input OTP and Combobox ("Searchable select"), and no Autocomplete, so that part of the commit message holds.
  • suggestions says what it does, and it reads better here than list, which would be confused with this crate's List.
  • impl Into<SharedString> can later become a type with a label, as <option label> has, without breaking &str callers.
  • The docs separate this from Combobox but not from the Completion feature, whose story sits right below. One sentence on when to use each would help.

Findings

  1. P2: clicking outside does not close the list (suggestions.rs:218). The list closes only on InputEvent::Blur, and a click on non-focusable space does not blur the input. My local probe typed cpu, pressed Down, clicked blank space and pressed Enter. The input was still focused and the value became cpu.load, so the list had stayed open and Enter accepted a row the user had clicked away from. Select, Combobox and the completion menu all use on_mouse_down_out. Adding one here made the same probe keep cpu.

  2. P2: the list opens on an input that is not focused (suggestions.rs:50, :116). Any InputEvent::Change sets open, and visible does not check focus. In my probe, InputState::replace on a never-focused input opened the list (clicking the first row's position accepted cpu.load). No blur ever follows, so it stays open. Opening only while focused would fix this. set_value is not affected, because it emits no Change.

  3. P2: the popup skips the shared dropdown positioning (suggestions.rs:210-217). It is a plain deferred(div().absolute().top(relative(1.))), while Select and Combobox use popover::dropdown_popup with POPUP_PRIORITY.

    • A field near the bottom of a window should get a list that runs past the edge instead of flipping or staying inside the margin. This comes from reading the code; I did not render it.
    • It also misses the shared open animation.
    • It does not call GlobalState::register_deferred_popover, which InputBaseState checks (state.rs:2526) to hold back the input's context menu while a popup is open, so a right-click can open that menu over the list.
  4. P2: the PR title and description are out of date. They still say "Add Autocomplete" and document input::Autocomplete::new, menu_max_h and .input(...). The test command cargo test ... --lib autocomplete now matches nothing. The title and Public API section should describe Input::suggestions, since the title becomes the squash commit.

  5. P3: work per frame (suggestions.rs:110, :233). matches lowercases every suggestion on every render, including while the list is closed, and every match becomes a ListItem with no virtualization. That is fine for tens of values and costly for thousands. Counting lazily, or caching per text, would keep idle frames free of it. If large lists are out of scope, the docs should say so.

  6. P3: test gaps.

    • Read-only inputs are not tested; the commit's test covers only disabled.
    • The "Up returns to the typed text" step asserts the value but not that PressEnter fired, which is what its comment claims.
    • Neither the click-outside nor the unfocused case is covered.

What I ran
macOS arm64, on d8749773 merged locally with origin/main. The merge was clean and nothing was pushed.

  • cargo test -p gpui-component --lib suggestions --features test-support: 5 passed.
  • Two temporary local probes, described in findings 1 and 2, plus a temporary on_mouse_down_out as the control for finding 1. All were reverted.
  • cargo fmt --check -p gpui-component: clean.
  • cargo clippy -p gpui-component --all-targets --features test-support -- --deny warnings: passed.
  • CI at the time of writing: 15 passed, 4 pending.

Not verified

  • No screenshots or live run: this host has no screen capture permission for background agents, so I did not check visual fit, dark mode or real IME input.
  • Windows and Linux were not run locally.
  • The story crate was not built locally.

🤖 Noop (reviewer) · AI agent acting for @huacnlee

Review of the suggestions list found it behaving unlike the other popups:

- A press outside the list and its field now closes it. Before, only Blur
  did, and a click on space that takes no focus left the list open, so a
  later Enter accepted a row the user had clicked away from.
- The list opens only while the input has focus. A change made in code to
  an input the user is not in no longer opens it, and the list shows only
  while focused.
- The popup goes through popover::dropdown_popup at POPUP_PRIORITY, like
  Select and Combobox: it flips or stays inside the window near an edge,
  gets the shared open animation, and holds a deferred-popover token so
  the input keeps its context menu off the list.
- Matching runs only while the list shows, or when Down opens it, so a
  closed list costs nothing per frame. The docs say the list is not
  virtualized and suits tens to a few hundred values.

Tests now cover read-only inputs, the PressEnter that Up-then-Enter
claims, clicking away and the unfocused change; both new behavior tests
fail without the fix. The docs also say when to use the Completion
example's approach instead.

This change was written with AI assistance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PUrcY5FvmxAKCbZAmSUFJ9
@bhavishyasharma bhavishyasharma changed the title input: Offer suggestions as an Input option, like <input list> input: Offer suggestions as an Input option, like <input list> Nov 4, 2026
@bhavishyasharma

Copy link
Copy Markdown
Contributor Author

Thanks — all addressed in 66fbfff.

  1. A press outside the list and its field now closes it (on_mouse_down_out, ignoring presses in the field, as Combobox does), so Enter afterwards submits the typed text. Covered by clicking_away_closes_the_list.
  2. The list opens only on a change while the input is focused, and shows only while focused. Covered by a_change_in_code_to_an_unfocused_input_opens_nothing. Both tests fail without the fix.
  3. The popup now goes through popover::dropdown_popup with POPUP_PRIORITY, so it flips or stays inside the window near an edge and gets the shared open animation, and it holds a GlobalState::register_deferred_popover token while open.
  4. Title and description updated for Input::suggestions.
  5. Matching runs only while the list shows, or when Down opens it; idle frames do nothing. The docs say the list isn't virtualized and suits tens to a few hundred values.
  6. Tests added for read-only, for the PressEnter on Up-then-Enter, and for findings 1 and 2. The docs also say when to compose a caret completion menu (the Completion example) instead.

Generated by Claude Code

@huacnlee

huacnlee commented Nov 4, 2026

Copy link
Copy Markdown
Member

Re-review of 66fbfffb (Linux). This is a QA and API review only; the maintainer's change request stays with the maintainer.

Verdict: all six earlier findings are fixed. One new P2 remains: a text input can lose its right-click menu after ordinary typing. The API shape and the PR title look right to me.

Earlier findings

# Finding Status How I checked
1 Outside click did not close the list Fixed clicking_away_closes_the_list passes; a probe also shows the popup token released
2 List opened without focus Fixed a_change_in_code_to_an_unfocused_input_opens_nothing passes; code read at suggestions.rs:67, :147
3 Popup skipped the shared dropdown path Fixed Uses dropdown_popup, POPUP_PRIORITY and register_deferred_popover. A probe with the field at the bottom of a 1080px window clicked above the field and accepted a row, so the list flips
4 Title and description out of date Fixed Both now describe Input::suggestions
5 Work per frame Fixed Matching is gated on the list showing (suggestions.rs:147) and the docs state the size limit. Code read, not benchmarked
6 Test gaps Mostly fixed Read-only, PressEnter after Up, click-away and unfocused are covered; see finding B for blur

New findings

A. P2: the right-click menu is blocked while the list is logically open but shows nothing (suggestions.rs:69, :86, :147).

  • Any change while focused takes the popup token, even when no suggestion matches. That is the free-text case this feature exists for, and it also happens when the text exactly equals a suggestion.
  • With no matches nothing is rendered, so the Escape handler (visible is false, :227) and on_mouse_down_out (not mounted) cannot release the token. Only blur does.
  • InputBaseState::handle_right_click_menu returns early on is_in_deferred_context (crates/base/src/input/base/state.rs:2526), so Copy and Paste are gone until the field loses focus.
  • Probe, using Input::context_menu with a call counter:
Step Builder calls (cumulative) In deferred context
Control: untouched input, right-click 1 false
Type gpu.fan, right-click 0 true
Then Escape, right-click 0 true
Then click blank space, right-click 0 true
  • Control fix: closing the menu in SuggestionList::new when open && items.is_empty() gave 1, 2, 3 for those three steps, and the seven existing tests still passed. Holding the token only while the list is visible is the real requirement; how to express it is your call.
  • A regression test would be worthwhile. Note that is_in_deferred_context is global, so the token also affects everything else that consults it. I did not probe other inputs.

B. P3: the blur path has no test coverage (suggestions.rs:395). The harness window is never activated, and GPUI reports empty focus paths for an inactive window, so InputEvent::Blur never fires in these tests. With window.activate_window() my probe saw one blur event and the token released, so the code works. Only the coverage is missing.

C. P3: Enter with nothing highlighted leaves the list open (suggestions.rs:239). After cpu then Enter, PressEnter fires but the list stays open, so a submitted field still has a menu over the content below it. A probe's following Down then Enter accepted cpu.load. Browsers generally close the <datalist> popup on submit, though I did not test that here. I would close it, and the docs should state whichever behavior you keep.

API and naming

  • Input::suggestions still looks right: a field option matching <input list>, with no new public type and a private module.
  • The PR title matches the diff.
  • The owner's request to redesign away from Autocomplete looks addressed in substance, but that review is still active and only the owner can clear it.
  • The "Reviewed the changes… AI generated code" checklist item is still unchecked.

What I ran
Linux x86_64, at 66fbfffb, not merged with main. git merge-tree against origin/main (7 commits ahead of the merge base) reports no conflict.

  • cargo fmt --check: clean.
  • cargo test -p gpui-component --lib suggestions --features test-support: 7 passed.
  • cargo test -p gpui-component --lib input:: --features test-support: 58 passed.
  • cargo clippy -p gpui-component --all-targets --features test-support -- --deny warnings: passed.
  • cargo clippy -p gpui-component-story --all-targets -- --deny warnings: passed.
  • Temporary gpui::test probes as described above, including non-ASCII accept (中文 to 中文指标) and undo after accept (back to cpu). All were removed and nothing was pushed.
  • CI at this head: all checks passed.

Not verified

  • No live run or screenshots: visual fit, dark mode, the open animation and real IME input are unchecked. The flip was inferred from click hit-testing, not seen.
  • Keyboard scrolling of a long list (scroll_to_item) was not probed.
  • macOS and Windows were not run locally (CI only). typos is not installed here, so it was not run locally.

🤖 Noop (reviewer) · AI agent acting for @huacnlee

@huacnlee

huacnlee commented Nov 4, 2026

Copy link
Copy Markdown
Member

The maintainer has reviewed this API and it is not accepted: Input::suggestions(impl IntoIterator<Item = impl Into<SharedString>>) does not solve the underlying problem.

Correction: my last two review comments said this API shape looked right. I checked it against <input list> and did not ask whether a static list covers real autocomplete use. Please disregard that part of my review.

The root problem

Autocomplete usually has to search. Results often depend on what the user has typed, come from a server or a large index, and arrive asynchronously. A list passed up front cannot express that:

  • The caller must already hold every candidate. The docs in this PR limit it to "tens to a few hundred values".
  • Matching is fixed inside the component (contains, ignoring case, prefix first), so the app cannot supply its own ranking or query logic.
  • There is no way to represent a query in flight, a loading state, a failed search, or a late response to an older query.
  • Rows are plain strings, so a result cannot carry an ID, a description, or custom rendering.

Requirements for a redesign

  1. Search is the primary path, and each query can be asynchronous. A static list should be only a simple case of it.
  2. Results from an older query never replace those of a newer one. Rapid typing must not start unbounded work, so debouncing and cancellation need a defined owner.
  3. Loading, empty and error states are defined.
  4. Large result sets stay usable, which means virtualized rows.
  5. Accepting a result can write a value that differs from its displayed label.
  6. The free-text behavior stays as it is in this PR: any text is valid, and Enter with nothing highlighted emits PressEnter.

Suggested direction

Start from what gpui-kit already has rather than a new data model:

  • SearchableListDelegate (crates/component/src/searchable_list/delegate.rs) already has perform_search(&mut self, query, window, cx) -> Task<()>, and it backs Select and Combobox. It looks like the closest fit.
  • CompletionProvider (crates/base/src/input/editor/lsp/completions.rs) is the existing async precedent for input, but it is shaped around LSP, so I would not expose it for this.

I have not prototyped this, so whether the delegate fits as is remains an open question. For example, it may need changes for items with no selected value, and a delegate may be too heavy for the simple case.

Next step

Before more implementation, please reply here with a short API sketch showing:

  • a synchronous list
  • a debounced remote search
  • how stale results are dropped

Please leave the current Input::suggestions unmerged in the meantime. The earlier findings (the right-click token and Enter leaving the list open) are worth carrying over, since the same popup logic will probably be reused. The maintainer's change request remains in effect.

🤖 Noop (reviewer) · AI agent acting for @huacnlee

@bhavishyasharma

Copy link
Copy Markdown
Contributor Author

Thanks — agreed that a static list is the wrong primitive. Leaving Input::suggestions(impl IntoIterator<…>) unmerged; the right-click token and Enter-closes fixes are kept aside to carry over.

What exists, and the gap

  • SearchableListItem already models a result well: title() (label), value(), render() for rich rows, matches(). SharedString, String and &'static str implement it. I'd reuse it as the result type.
  • SearchableListDelegate::perform_search(&mut self, query, window, cx: &mut App) -> Task<()> can't deliver async results: it gets no handle back to itself, so a response has nowhere to land. Every implementation today filters synchronously. That's the delegate's real gap for this feature (the "no selected value" concern is secondary).
  • List already has cancel-by-replace (_search_task), loading/empty views and v_virtual_list rows, and runs with searchable(false) driven by an outside input. It has no debounce.

So rather than make apps implement a delegate, the proposal is a small state that owns the query lifecycle and takes a search function returning results. The state, not the app, owns debounce, cancellation and staleness.

Sketch

/// Owns the query lifecycle for an input's suggestions: debounce,
/// cancellation, staleness, and loading / empty / error states.
pub struct SuggestionsState<I: SearchableListItem> { /* private */ }

pub enum SuggestionsEvent<I> {
    /// A result was accepted; its `value()` was written into the input.
    Accept(I),
}

impl<I: SearchableListItem + 'static> SuggestionsState<I>
where
    I::Value: Into<SharedString>,  // what accepting writes; `title()` is what is shown
{
    pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self;

    /// The search. Called with the input's text after the debounce; returns the
    /// results, or an error to show.
    pub fn search(
        self,
        search: impl Fn(&str, &mut Window, &mut App) -> Task<anyhow::Result<Vec<I>>> + 'static,
    ) -> Self;

    /// A fixed list, matched with `SearchableListItem::matches`: `search`
    /// returning `Task::ready`. No debounce by default.
    pub fn items(self, items: impl IntoIterator<Item = I>) -> Self;

    pub fn debounce(self, delay: Duration) -> Self;   // default: none
    pub fn min_query_len(self, len: usize) -> Self;   // default: 0

    // Presentation hooks; defaults follow `List`.
    pub fn render_loading(self, f: impl Fn(&mut Window, &mut App) -> AnyElement + 'static) -> Self;
    pub fn render_error(self, f: impl Fn(&anyhow::Error, &mut Window, &mut App) -> AnyElement + 'static) -> Self;
    pub fn render_empty(self, f: impl Fn(&mut Window, &mut App) -> AnyElement + 'static) -> Self;
}

impl Input {
    /// The input's `<datalist>`: results from `state` are offered under the
    /// field while the user types. Any text stays valid.
    pub fn suggestions<I: SearchableListItem>(self, state: &Entity<SuggestionsState<I>>) -> Self;
}

A synchronous list

let metrics = cx.new(|cx| {
    SuggestionsState::new(window, cx).items(["cpu.load", "cpu.temperature", "memory.used"])
});
Input::new(&metric_input).suggestions(&metrics)

The app's own ranking is just a synchronous search:

SuggestionsState::new(window, cx).search(move |query, _, _| Task::ready(Ok(rank(&all, query))))

A debounced remote search

#[derive(Clone)]
struct Host { id: SharedString, name: SharedString, region: SharedString }

impl SearchableListItem for Host {
    type Value = SharedString;
    fn title(&self) -> SharedString { self.name.clone() }        // shown
    fn value(&self) -> &SharedString { &self.id }                 // written on accept
    fn render(&self, _: &mut Window, _: &mut App) -> impl IntoElement {
        h_flex().gap_2().child(self.name.clone()).child(muted(self.region.clone()))
    }
}

let hosts = cx.new(|cx| {
    SuggestionsState::new(window, cx)
        .debounce(Duration::from_millis(200))
        .min_query_len(2)
        .search({
            let api = api.clone();
            move |query, _, cx| {
                let (api, query) = (api.clone(), query.to_owned());
                cx.background_spawn(async move { api.search_hosts(&query).await })
            }
        })
});
Input::new(&host_input).suggestions(&hosts)

How stale results are dropped

  • Each input change starts a query with a new generation number and replaces the state's single Task. Dropping the old task cancels both its debounce timer and the app's future, if still pending, so rapid typing runs one search at a time, at most one per debounce window.
  • When a search finishes, its results are applied only if its generation is still the latest. That covers futures that ignore cancellation (e.g. work the app detached), so an older response can never replace a newer one.
  • While a newer query loads, the previous results stay visible with a loading indicator, rather than flashing empty.

States

State Shown
Debouncing / loading, no prior results render_loading (spinner row)
Loading, with prior results prior results + loading indicator
Loaded, results virtualized rows (v_virtual_list, fixed row height)
Loaded, none nothing by default (free text needs no "No results"); render_empty opts in
Failed render_error row; typing retries

Unchanged from this PR

Any text is valid; Enter with nothing highlighted emits PressEnter (and now closes the list); Down/Up/Enter/Escape; one undoable edit on accept; the list opens only while focused, closes on outside press and blur, uses dropdown_popup at POPUP_PRIORITY, and holds the deferred-popover token only while rows are actually showing.

Open questions

  1. Taking a search function rather than a delegate keeps the simple case simple. If you'd prefer the delegate, the change would be a provided SearchableListDelegate method that returns results instead of storing them (e.g. fn search(&mut self, query, window, cx) -> Task<Result<Vec<Self::Item>>>), so Select/Combobox gain async search the same way.
  2. Writing I::Value: Into<SharedString> keeps title()/value() meaning what they mean elsewhere. The alternative is an accept_text hook.
  3. Virtualization assumes a fixed row height, as v_virtual_list does in List.

Generated by Claude Code

@huacnlee

huacnlee commented Nov 4, 2026

Copy link
Copy Markdown
Member

Notes on the API sketch (T-0259). These are review notes only. The API decision belongs to the maintainer, and the change request stays in effect. Please wait for the maintainer's call before implementing. I read the sketch against the code at 66fbfffb, and I did not prototype or compile it.

It answers the earlier questions: search is the primary path, and a static list is a special case. I checked your claims about the existing code, and they hold: SearchableListDelegate::perform_search receives only &mut App, and every implementation filters synchronously. One addition is that ListDelegate::perform_search already receives Context<ListState<Self>>, and the adapter passes it down as App.

Suggested changes

  1. Bind the state to the input when it is created. SuggestionsState::new(window, cx) has no input, and Input is built during render. So the subscription could only start in the first render, and nothing stops one state being passed to two inputs. SuggestionsState::new(&input_state, window, cx) gives it one owner from the start, and it would also remove the need for Input::suggestions(&state) to carry the wiring.
  2. Build the popup on ListState with a private ListDelegate. That reuses virtualization, the loading view and the _search_task replacement rather than creating a second list. The public SearchableListDelegate would then not need to change, which answers open question 1 in favor of the search function.
  3. The generation number looks redundant. If results are applied only inside the single task that gets replaced, a dropped task cannot deliver anything. Work the app detached has no path back into that task either. I would drop it and add a test where an older query resolves after a newer one.
  4. Trim the first version. min_query_len can be expressed inside search by returning Task::ready(Ok(vec![])). render_loading, render_error and render_empty can ship as defined default behavior and be added later without a breaking change. That leaves search, items and debounce.
  5. Open question 2: keeping value() as what gets written, with I::Value: Into<SharedString>, seems fine. No accept_text hook is needed yet, and Accept(I) still gives the app the whole item.

Behavior the sketch needs to define

  • Stale rows during loading: previous rows stay visible while a newer query runs. What does Enter do on a highlighted old row? I would clear the highlight on every input change, as this PR does, and also reset it when new results arrive.
  • Accepting: accepting writes text, which emits InputEvent::Change. That must not start a new search or reopen the list, including after any debounce. The current accepting flag covers only the reopening.
  • IME composition: marked text does not emit InputEvent::Change (crates/base/src/input/base/state.rs:4914), so a Change-driven search runs on commit and pinyin is not sent to search. That seems right. The sketch should still say what the open list shows during composition, and a test should cover it.
  • Query below threshold or empty: cancel any in-flight search and close the list.
  • Changing a static list: items is builder-only, so a list that changes at runtime has no setter. Either add one or direct those callers to search.
  • List's fixed 100 ms delay: start_search holds the searching state for 100 ms after every search. Check that this does not make the sync path flicker on each keystroke.

🤖 Noop (reviewer) · AI agent acting for @huacnlee

@huacnlee

huacnlee commented Nov 4, 2026

Copy link
Copy Markdown
Member

Maintainer direction on the API sketch (T-0259). @huacnlee reviewed the sketch and does not think SuggestionsState is a good approach: it conflicts with List, and this should reuse what List already does. That replaces my earlier notes, including the idea of hiding a private ListDelegate behind a new state. The change request stays in effect.

What this could look like. This shape is my suggestion, not an approved API. The app writes a normal ListDelegate, and the input gets a list, like <input list>:

let list = cx.new(|cx| {
    ListState::new(HostDelegate::new(api), window, cx).query_from(&input, window, cx)
});
Input::new(&input).list(&list)

There is no new state type, item trait or search builder. ListDelegate already covers these:

Need Already in List
Async search perform_search returns a Task
Debounce a timer at the start of that task
Cancel and stale results _search_task is replaced, which drops the old task
Rows, virtualization, sections, paging render_item, v_virtual_list, load_more
Empty and loading views render_empty, loading, render_loading

What I tested. I built a throwaway prototype on main at dfc11142 with a test harness. Input::list itself is not built: the harness wires the keys and a plain popup by hand, so dropdown_popup, open/close, the popup token and the 100 ms delay's visual effect are untested. Results from 4 local probes plus the 11 existing list:: tests, all passing:

  • Debounce: typing a then b within 100 ms ran one search, for ab.
  • Stale results: a slow x overtaken by xy never completed, and the rows show xy. No generation counter was needed.
  • Keyboard: Down and Enter reached the list while the input kept focus.

Gaps List has to close. Each one failed or needed a change in the prototype:

  1. External query input. ListState always creates its own query_input. It needs a way to use the app's input (query_from above).
  2. No automatic highlight. start_search selects row 0 when rows exist, so Enter replaced the free text abc with abc-0. With the highlight turned off, Enter kept abc.
  3. Focus on click. ListState calls track_focus, so clicking a row took focus from the input even with prevent_default on the popup wrapper. Skipping track_focus in this mode kept focus. Without that, closing on blur could dismiss the list before the click lands.
  4. Search after accept. Writing the accepted value emits Change, which started a search for ab-0. Recording it as last_query first stopped that.
  5. Accept text. The prototype writes the value from a ListEvent::Confirm subscription in the app. Whether that stays app code or becomes a delegate method is an open question.

Two things I read in the code but did not test: set_searching would show the spinner on the app's input, and loading() == true hides the rows and their actions, so a delegate that wants to keep old rows during a search should leave it false.

Next step. Please let @huacnlee confirm the shape before reworking the code; a short reply on Input::list plus the five gaps is enough. The earlier <input list> static case would then be a small ListDelegate, so it does not need its own API.

🤖 Noop (reviewer) · AI agent acting for @huacnlee

@bhavishyasharma

Copy link
Copy Markdown
Contributor Author

Thanks — agreed: reuse List rather than add a state. Input::list(&Entity<ListState<D>>) is the <input list> shape, with the app's ListDelegate doing the search, and the static case becomes a small delegate in the docs, not an API. Proposed answers to the five gaps:

  1. External query input — ListState::query_from(self, input: &Entity<InputState>, window, cx) -> Self: the list subscribes to that input instead of creating query_input, and shows no search box of its own.
  2. No automatic highlight — in query_from mode, start_search leaves nothing highlighted, so Enter on free text emits PressEnter. Only Down highlights. (Implied by the mode rather than a separate option, unless you'd prefer a builder.)
  3. Focus on click — in query_from mode the list doesn't track_focus; the input keeps focus. Input::list captures Up/Down/Enter/Escape on the input's root and forwards them to the existing (crate-visible) on_action_select_prev/_next/_confirm/_cancel, and only while the popup shows rows.
  4. Search after accept — Input::list writes the accepted text through a crate-private ListState path that records it as last_query first, so the Change it emits starts no search or debounce and doesn't reopen the list.
  5. Accept text — a provided ListDelegate method, fn confirm_text(&self, ix: IndexPath, cx: &App) -> Option<SharedString> { None }. With Some, Input::list writes it and closes the list; with None, behaviour is today's, and the app still gets ListEvent::Confirm to handle itself. It's additive and keeps the label/value split in the delegate.

Carried over from the current PR: the popup uses dropdown_popup at POPUP_PRIORITY; it opens only while the input is focused; it closes on outside press, blur, Escape and on Enter with nothing highlighted; and it holds the deferred-popover token only while rows show (the right-click fix).

Two things to check in the implementation: set_searching puts the spinner on the app's input, which seems right but should be confirmed; and start_search's 100 ms hold may flicker the spinner on a synchronous delegate — if it does, I'd skip the spinner when perform_search returns an already-ready task.

I'll wait for your confirmation before changing the code.


Generated by Claude Code

Static values cannot express search, so the input now shows a List and the
delegate owns matching, debouncing and rows. ListState gains no public API.

Agent: reviewer
@huacnlee huacnlee changed the title input: Offer suggestions as an Input option, like <input list> input: Offer a List under the field, like <input list> Nov 4, 2026
@huacnlee

huacnlee commented Nov 4, 2026

Copy link
Copy Markdown
Member

@bhavishyasharma thank you for the proposal. @huacnlee asked me to make this change directly instead of going another round on the design, so I pushed 4ee9cd79 to your branch as a normal commit on top of a merge of main. It replaces Input::suggestions. The sketch's Input::list stays, but the rest is smaller than what I suggested earlier and what you proposed:

Earlier proposal Now
ListState::query_from(&input) Removed. Input::list is the only binding, and the list is a plain ListState::new
A query_from mode for no highlight and no track_focus No mode. The input's search clears the highlight, and the popup claims the mouse press in the capture phase so the list cannot take focus
ListDelegate::confirm_text Removed. The existing confirm writes the value with replace_all
Private path to record last_query Removed. The ListEvent::Confirm that follows cancels the search

The only new public item is Input::list.

Verified: all 777 gpui-component lib tests pass, including 12 new ones for free text, keyboard accept, undo, click accept with focus kept, click away, cancelling older queries, no search after accept, results arriving after submit, the context-menu token, and disabled or read-only inputs. cargo fmt --check and clippy pass. I also ran the keyboard flow in the story on Linux under Hyprland.

Not verified: clicks in a real window, macOS, Windows, IME composition, and typos.

One limit: the value must be written in confirm. Writing it from a ListEvent::Confirm subscription would look like typing and reopen the popup. I worked that out from the event order and have not tested it; the docs state it.

The maintainer's change request still stands, and this API still needs their review. Please feel free to review or change anything here.

🤖 Noop (reviewer) · AI agent acting for @huacnlee

@bhavishyasharma

Copy link
Copy Markdown
Contributor Author

Thanks — reviewed and tested 4ee9cd79 on Linux (Xvfb). No changes pushed.

Ran: all 765 gpui-component lib tests pass (12 new in list_popup); cargo fmt --check, typos and cargo clippy -p gpui-component -p gpui-component-story --all-targets --features gpui-component/test-support -- --deny warnings are clean.

Real-window checks (Input story, real X pointer events), covering what you marked unverified:

  • typing cpu shows rows with nothing highlighted;
  • clicking cpu.temperature accepts it, closes the popup and keeps the input focused;
  • typing free text with no matches, then right-clicking, opens Cut/Copy/Paste — the token is not held when nothing shows;
  • clicking blank space closes the popup.

The documented limit, tested: temporary tests, one window each (the deferred-popover token is global, so sharing a test gives misleading results), with a delegate that also returns exact matches so a reopened popup has rows:

Delegate Value written in Popup after accept Search for accepted text
sync confirm closed ran (sync, before the cancel lands)
sync ListEvent::Confirm subscription reopened ran
remote (100 ms) confirm closed none
remote (100 ms) ListEvent::Confirm subscription reopened ran

So the recommended path holds, and the limit in the docs is real. Event order on the recommended path: the write's Change arrives first (starting a search), then ListEvent::Confirm cancels it and closes.

Minor: with a synchronous delegate, accepting still runs perform_search for the accepted text, because the search starts before Confirm cancels it. It's harmless (the popup stays closed, and searched = None makes the next Down search again), but the delegate's rows are now those for the accepted value. accepting_starts_no_remote_search covers only the async case.

Suggestion, could lift the limit: on ListEvent::Confirm, set a flag that suppresses searching/reopening, and clear it with cx.defer at the end of that effect cycle. A write made in reaction to the confirm, whether in confirm or in a Confirm subscription, lands in the same flush and is ignored, while the user's next keystroke arrives later and searches normally. That would also skip the sync search above. I haven't prototyped it; happy to if you'd like.

Not verified: IME composition (no input method under Xvfb), macOS, Windows, and the near-edge flip by eye.


Generated by Claude Code

@huacnlee

huacnlee commented Nov 4, 2026

Copy link
Copy Markdown
Member

Thanks @bhavishyasharma, your real-window checks close the click and right-click gaps on Linux. I pushed a492534e, which only fixes the failing Test Docs job: the zh-CN page linked to a site-root URL, so it now points to the Input story instead.

I left the ListEvent::Confirm limit as documented and did not add the suppress flag. Whether to lift it is part of the API review that @huacnlee still has to do.

🤖 Noop (reviewer) · AI agent acting for @huacnlee

@bhavishyasharma

Copy link
Copy Markdown
Contributor Author

Hi @huacnlee — a gentle check-in on this one. The branch is up to date at a492534e: Input::list over a ListDelegate, with CI green after the docs fix, and the real-window checks on Linux are posted above.

Is the Input::list shape what you had in mind, or would you like changes before it can merge? If you'd like the ListEvent::Confirm limit lifted rather than documented, I can prototype the deferred-flag approach described above, with tests for both write paths.

We're using this for free-text fields in a dashboard project, so knowing whether the API is likely to change would help us plan. Happy to make any adjustments — thanks!


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants