Skip to main content

teksilo_widgets/code_editor/
completion.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Injected code completion: a caret-anchored suggestion popup.
5//!
6//! Language-agnostic like everything else in this module. The editor knows how
7//! to show a list, filter it by the word before the caret, and replace that word
8//! on accept; the *candidates* come from an application-supplied provider
9//! (`Fn(&CompletionContext) -> Vec<CompletionItem>`) — keywords, identifiers in
10//! scope, an LSP's reply, whatever the app knows. The editor knows nothing about
11//! any language.
12//!
13//! # Why the editor owns the keys
14//!
15//! Unlike a ComboBox — whose dropdown keeps focus *inside* the overlay so arrow
16//! keys bubble to it — a completion popup keeps focus in the **editor** (you are
17//! still typing). The popup is a detached overlay, not an ancestor of the focused
18//! editor, so keys cannot bubble to it. The editor's own keyboard handler
19//! therefore drives navigation directly while the popup is open, and this module
20//! drives trigger / filter / dismiss from the document state after each edit. The
21//! popup widget ([`CompletionPanel`]) is purely presentational: it renders the
22//! current session from the shared state and rebuilds when the selection signal
23//! changes — set on every (re)filter as well as every arrow move, so one
24//! binding covers both.
25//!
26//! # Accessibility
27//!
28//! The listbox pattern every value-picker in the framework uses (ComboBox,
29//! SearchField): the editor's node keeps focus and carries `HasPopup::Listbox` +
30//! `AutoComplete::List`, announces `expanded`, and points `active_descendant` at
31//! the highlighted row; the popup is a `Role::ListBox` of `Role::ListBoxOption`
32//! rows. Focus never moves into the popup.
33//!
34//! ## Touch and pen
35//!
36//! A suggestion row takes the menu row's target floor at every density, which
37//! raises it by 2 dp at Compact — the documented `MinSize`-is-a-hit-box exception,
38//! because a stack of adjacent rows is the one shape the hit mechanisms cannot
39//! serve: an outset on each row only moves the boundaries between them, the
40//! neighbour it would borrow from being another row.
41
42use std::cell::Cell;
43use std::rc::Rc;
44
45use teksilo_canvas::Point;
46use teksilo_core::Signal;
47use teksilo_core::accesskit::Role;
48use teksilo_core::build_context::BuildContext;
49use teksilo_core::overlay::{
50    DismissBehavior, OverlayDismissCallback, OverlayPlacement, OverlayRequest,
51};
52use teksilo_core::widget::{EventContext, LayoutContext, LayoutResponse, Widget};
53use teksilo_core::widget_builder::WidgetBuilder;
54use teksilo_core::widget_id::WidgetId;
55use teksilo_tokens::{SurfaceRole, TextRole, TextStyleRole};
56
57use super::state::{CodeEditorState, SharedState};
58use super::{semantics, sync_cursor_signals};
59
60/// The most rows a completion popup shows at once; a longer filtered list
61/// windows around the selection.
62const MAX_VISIBLE_ROWS: usize = 10;
63
64// ─────────────────────────────────────────────────────────────────────────
65// Public types
66// ─────────────────────────────────────────────────────────────────────────
67
68/// A completion candidate. Build with [`CompletionItem::new`] and the fluent
69/// setters; `insert_text` defaults to `label`.
70#[derive(Debug, Clone)]
71pub struct CompletionItem {
72    /// The text shown in the list.
73    pub label: String,
74    /// The text that replaces the word being completed when accepted. Defaults
75    /// to `label`.
76    pub insert_text: String,
77    /// Optional dimmed detail shown at the trailing edge of the row (a type, a
78    /// signature, a source).
79    pub detail: Option<String>,
80    /// A category driving the row's leading badge — purely visual, no behaviour.
81    pub kind: CompletionKind,
82}
83
84impl CompletionItem {
85    /// A candidate whose inserted text is its label.
86    pub fn new(label: impl Into<String>) -> Self {
87        let label = label.into();
88        Self {
89            insert_text: label.clone(),
90            label,
91            detail: None,
92            kind: CompletionKind::Text,
93        }
94    }
95
96    /// Override the text inserted on accept (when it differs from the label).
97    pub fn insert_text(mut self, text: impl Into<String>) -> Self {
98        self.insert_text = text.into();
99        self
100    }
101
102    /// Trailing dimmed detail (a type or signature).
103    pub fn detail(mut self, detail: impl Into<String>) -> Self {
104        self.detail = Some(detail.into());
105        self
106    }
107
108    /// The leading badge category.
109    pub fn kind(mut self, kind: CompletionKind) -> Self {
110        self.kind = kind;
111        self
112    }
113}
114
115/// The category of a completion candidate — drives a small leading badge only.
116/// Deliberately a fixed, language-neutral set: the editor renders a glyph, the
117/// application decides which candidate is which kind.
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum CompletionKind {
120    Text,
121    Keyword,
122    Function,
123    Method,
124    Variable,
125    Field,
126    Type,
127    Module,
128    Constant,
129    Snippet,
130}
131
132impl CompletionKind {
133    /// A short badge glyph. Kept to letters so it renders in any font (no icon
134    /// dependency) and reads under a screen magnifier.
135    fn badge(self) -> &'static str {
136        match self {
137            CompletionKind::Text => "a",
138            CompletionKind::Keyword => "k",
139            CompletionKind::Function => "ƒ",
140            CompletionKind::Method => "m",
141            CompletionKind::Variable => "v",
142            CompletionKind::Field => "•",
143            CompletionKind::Type => "T",
144            CompletionKind::Module => "☐",
145            CompletionKind::Constant => "c",
146            CompletionKind::Snippet => "▢",
147        }
148    }
149}
150
151/// What a completion provider is told about the caret when asked for candidates.
152pub struct CompletionContext<'a> {
153    /// The identifier characters immediately before the caret.
154    pub prefix: &'a str,
155    /// The whole current line.
156    pub line: &'a str,
157    /// The caret's column within the line (character index).
158    pub column: usize,
159    /// The caret's absolute document position.
160    pub position: usize,
161}
162
163/// The application-supplied source of candidates.
164pub(super) type Provider = Rc<dyn Fn(&CompletionContext) -> Vec<CompletionItem>>;
165
166// ─────────────────────────────────────────────────────────────────────────
167// Session + state
168// ─────────────────────────────────────────────────────────────────────────
169
170/// The live completion for one word: the candidates the provider gave for it,
171/// and the subset matching the current prefix.
172struct Session {
173    candidates: Vec<CompletionItem>,
174    filtered: Vec<usize>,
175    /// Document position where the completed word starts — the anchor and the
176    /// replace-from point.
177    word_start: usize,
178}
179
180/// Completion configuration and live state, held on [`CodeEditorState`].
181pub(crate) struct CompletionState {
182    pub(super) provider: Option<Provider>,
183    /// Whether typing an identifier character opens the popup automatically.
184    pub(super) auto_trigger: bool,
185
186    session: Option<Session>,
187    /// The word position where Escape suppressed completion, so it does not
188    /// immediately reopen while the caret stays on that word.
189    suppressed_at: Option<usize>,
190
191    /// The pre-created, normally-dormant popup content node.
192    pub(super) panel_id: Option<WidgetId>,
193    /// The highlighted row's WidgetId, published by the panel build and read by
194    /// the body's a11y to point `active_descendant` at it (the roving-focus
195    /// pattern — focus stays on the editor).
196    pub(super) active_row: Rc<Cell<Option<WidgetId>>>,
197
198    /// Whether the popup is currently shown (drives the panel's `visible_when`,
199    /// the body's `expanded`, and the keyboard's routing).
200    pub open: Signal<bool>,
201    /// The highlighted row, as an index into the *filtered* list. Set
202    /// unconditionally on every (re)filter and every arrow move, so the panel —
203    /// bound to it at `Rebuild` — re-renders on both; no separate version signal
204    /// is needed.
205    pub selected: Signal<usize>,
206}
207
208impl CompletionState {
209    pub(super) fn new() -> Self {
210        Self {
211            provider: None,
212            auto_trigger: true,
213            session: None,
214            suppressed_at: None,
215            panel_id: None,
216            active_row: Rc::new(Cell::new(None)),
217            open: Signal::new(false),
218            selected: Signal::new(0),
219        }
220    }
221
222    pub(super) fn is_open(&self) -> bool {
223        self.open.get()
224    }
225
226    pub(super) fn has_provider(&self) -> bool {
227        self.provider.is_some()
228    }
229
230    /// Every filtered candidate, cloned — used only by tests. The panel clones
231    /// just its visible window via [`window_items`](Self::window_items).
232    #[cfg(test)]
233    fn visible_items(&self) -> Vec<CompletionItem> {
234        self.window_items(0, self.filtered_len())
235    }
236
237    /// The filtered candidates in `[start, end)`, cloned for the panel — only the
238    /// rows it will actually render, not the whole (possibly large) list.
239    fn window_items(&self, start: usize, end: usize) -> Vec<CompletionItem> {
240        match &self.session {
241            Some(s) => s.filtered[start.min(s.filtered.len())..end.min(s.filtered.len())]
242                .iter()
243                .map(|&i| s.candidates[i].clone())
244                .collect(),
245            None => Vec::new(),
246        }
247    }
248
249    /// Number of filtered rows.
250    fn filtered_len(&self) -> usize {
251        self.session.as_ref().map(|s| s.filtered.len()).unwrap_or(0)
252    }
253
254    /// The (word_start, insert_text) for a filtered index, if valid.
255    fn item_at(&self, filtered_index: usize) -> Option<(usize, String)> {
256        let s = self.session.as_ref()?;
257        let cand = *s.filtered.get(filtered_index)?;
258        Some((s.word_start, s.candidates[cand].insert_text.clone()))
259    }
260}
261
262// ─────────────────────────────────────────────────────────────────────────
263// Driver: trigger / filter / dismiss
264// ─────────────────────────────────────────────────────────────────────────
265
266/// Why `react` is running — decides whether the popup may *open* (only typing or
267/// an explicit request opens it; an edit or a move only updates or dismisses one
268/// already open).
269#[derive(Debug, Clone, Copy, PartialEq, Eq)]
270pub(super) enum Trigger {
271    /// An identifier character was typed.
272    Typed,
273    /// A deletion (Backspace / Delete).
274    Edited,
275    /// The caret moved without editing.
276    Moved,
277    /// An explicit request (Ctrl+Space).
278    Forced,
279}
280
281/// Re-evaluate completion after an edit, move, or explicit request. Opens,
282/// re-filters, or dismisses the popup as the document state dictates. A no-op
283/// without a provider.
284pub(super) fn react(state: &SharedState, ctx: &mut EventContext, trigger: Trigger) {
285    if !state.borrow().completion.has_provider() {
286        return;
287    }
288    // Flush any batched typing so the prefix reflects what the user actually
289    // typed (mirrors `type_bracket_char`); nothing to flush on a pure move.
290    // Keeping the caret signals in step, since the ordinary batch path would
291    // have synced them from the frame loop instead.
292    let flushed = {
293        let mut st = state.borrow_mut();
294        if st.pending_chars.is_empty() {
295            false
296        } else {
297            let batch = std::mem::take(&mut st.pending_chars);
298            super::frame_loop::insert_at_every_caret(&mut st, &batch);
299            true
300        }
301    };
302    if flushed {
303        sync_cursor_signals(state);
304    }
305
306    // Fetch a fresh word's candidates OUTSIDE any borrow: an app provider may
307    // reach back into the editor through a captured handle, and calling it while
308    // the RefCell is borrowed would panic. Nothing else runs between this read
309    // and the evaluate below, so the word the fetch is keyed to stays current.
310    let fetched = {
311        let req = {
312            let st = state.borrow();
313            prepare_fetch(&st, trigger)
314        };
315        req.map(|r| {
316            let cx = CompletionContext {
317                prefix: &r.prefix,
318                line: &r.line,
319                column: r.column,
320                position: r.position,
321            };
322            (r.word_start, (r.provider)(&cx))
323        })
324    };
325
326    let decision = {
327        let mut st = state.borrow_mut();
328        evaluate(&mut st, trigger, fetched)
329    };
330
331    match decision {
332        Decision::Open(anchor) => open_or_update(state, ctx, anchor),
333        Decision::Update => {}
334        Decision::Dismiss => close(state, ctx),
335        Decision::Idle => {}
336    }
337}
338
339enum Decision {
340    /// Show (or keep showing) the popup, anchored at this window point.
341    Open(Point),
342    /// Keep the open popup; content already refreshed via the selection signal.
343    Update,
344    /// Close the popup if open.
345    Dismiss,
346    /// Do nothing.
347    Idle,
348}
349
350/// What a fresh-word candidate fetch needs, gathered under a read borrow so the
351/// provider can then be called without one.
352struct FetchReq {
353    word_start: usize,
354    prefix: String,
355    line: String,
356    column: usize,
357    position: usize,
358    provider: Provider,
359}
360
361/// Decide, under a read borrow, whether a fresh-word provider fetch is warranted
362/// (the popup would proceed *and* the word changed). Mirrors evaluate's early
363/// gates exactly, so the two never disagree on which word is current.
364fn prepare_fetch(st: &CodeEditorState, trigger: Trigger) -> Option<FetchReq> {
365    // Single-caret only, no active selection.
366    if st.cursor.has_selection() || !st.extra_carets.is_empty() {
367        return None;
368    }
369    let was_open = st.completion.open.get();
370    let may_open = matches!(trigger, Trigger::Forced)
371        || (trigger == Trigger::Typed && st.completion.auto_trigger);
372    if !was_open && !may_open {
373        return None;
374    }
375    let pos = st.cursor.position();
376    let (word_start, prefix) = semantics::word_prefix_before_caret(st, pos);
377    if st.completion.suppressed_at == Some(word_start) && trigger != Trigger::Forced {
378        return None;
379    }
380    if prefix.is_empty() && trigger != Trigger::Forced {
381        return None;
382    }
383    // Only a genuinely new word needs a fetch; refining one reuses the cache.
384    let fresh = st
385        .completion
386        .session
387        .as_ref()
388        .map(|s| s.word_start != word_start)
389        .unwrap_or(true);
390    if !fresh {
391        return None;
392    }
393    let (line, column) = st
394        .document
395        .snapshot_block_at_position_without_highlights(pos)
396        .map(|b| (b.text, pos - b.position))
397        .unwrap_or_default();
398    Some(FetchReq {
399        word_start,
400        prefix,
401        line,
402        column,
403        position: pos,
404        provider: st.completion.provider.clone()?,
405    })
406}
407
408/// Test hook: run the fetch-then-evaluate cycle without an overlay, so the pure
409/// session transition is inspectable via [`CompletionState::test_labels`].
410#[cfg(test)]
411impl CompletionState {
412    pub(super) fn test_labels(&self) -> Vec<String> {
413        self.visible_items().into_iter().map(|i| i.label).collect()
414    }
415
416    pub(super) fn test_set_suppressed(&mut self, at: Option<usize>) {
417        self.suppressed_at = at;
418    }
419}
420
421#[cfg(test)]
422pub(super) fn test_evaluate(state: &SharedState, trigger: Trigger) {
423    let fetched = {
424        let req = {
425            let st = state.borrow();
426            prepare_fetch(&st, trigger)
427        };
428        req.map(|r| {
429            let cx = CompletionContext {
430                prefix: &r.prefix,
431                line: &r.line,
432                column: r.column,
433                position: r.position,
434            };
435            (r.word_start, (r.provider)(&cx))
436        })
437    };
438    let mut st = state.borrow_mut();
439    let _ = evaluate(&mut st, trigger, fetched);
440}
441
442/// The state transition: recompute the session and decide the popup's fate.
443/// `fetched` carries candidates already obtained (outside the borrow) for a
444/// fresh word — the provider is never called here. Sets the selection signal
445/// (a `bind_to` observer only marks the panel dirty, so no re-entrant borrow);
446/// the `open` signal is set later, outside any borrow, by the overlay calls.
447fn evaluate(
448    st: &mut CodeEditorState,
449    trigger: Trigger,
450    fetched: Option<(usize, Vec<CompletionItem>)>,
451) -> Decision {
452    let was_open = st.completion.open.get();
453
454    // A forced request lifts any Escape suppression for the current word.
455    if trigger == Trigger::Forced {
456        st.completion.suppressed_at = None;
457    }
458
459    // Completion is single-caret by decision: a selection or several carets means
460    // the user is doing something else. Accepting only ever touches the primary
461    // caret, so activating with extras would silently discard them.
462    if st.cursor.has_selection() || !st.extra_carets.is_empty() {
463        st.completion.session = None;
464        return if was_open {
465            Decision::Dismiss
466        } else {
467            Decision::Idle
468        };
469    }
470
471    // Cheap gate before the per-line prefix scan: a closed popup this trigger may
472    // not open has nothing to do (plain navigation with no popup, the common
473    // case for an editor that has a provider installed).
474    let may_open = matches!(trigger, Trigger::Forced)
475        || (trigger == Trigger::Typed && st.completion.auto_trigger);
476    if !was_open && !may_open {
477        return Decision::Idle;
478    }
479
480    let pos = st.cursor.position();
481    let (word_start, prefix) = semantics::word_prefix_before_caret(st, pos);
482
483    if st.completion.suppressed_at == Some(word_start) && trigger != Trigger::Forced {
484        return Decision::Idle;
485    }
486    if st.completion.suppressed_at.is_some() && st.completion.suppressed_at != Some(word_start) {
487        st.completion.suppressed_at = None;
488    }
489
490    // A move onto a different word closes an open popup (you navigated away).
491    if was_open
492        && trigger == Trigger::Moved
493        && st.completion.session.as_ref().map(|s| s.word_start) != Some(word_start)
494    {
495        st.completion.session = None;
496        return Decision::Dismiss;
497    }
498
499    // Nothing to complete on an empty prefix unless explicitly forced.
500    if prefix.is_empty() && trigger != Trigger::Forced {
501        st.completion.session = None;
502        return if was_open {
503            Decision::Dismiss
504        } else {
505            Decision::Idle
506        };
507    }
508
509    // (Re)build the session for a new word from the pre-fetched candidates; keep
510    // the cached ones while refining the same word.
511    let fresh_word = st
512        .completion
513        .session
514        .as_ref()
515        .map(|s| s.word_start != word_start)
516        .unwrap_or(true);
517    if fresh_word {
518        let candidates = match fetched {
519            Some((ws, cands)) if ws == word_start => cands,
520            _ => Vec::new(),
521        };
522        st.completion.session = Some(Session {
523            candidates,
524            filtered: Vec::new(),
525            word_start,
526        });
527    }
528
529    // Filter by the current prefix (case-insensitive prefix match). An empty
530    // prefix (forced) matches everything.
531    let lower = prefix.to_lowercase();
532    let filtered: Vec<usize> = {
533        let s = st.completion.session.as_ref().expect("session set above");
534        s.candidates
535            .iter()
536            .enumerate()
537            .filter(|(_, c)| lower.is_empty() || c.label.to_lowercase().starts_with(&lower))
538            .map(|(i, _)| i)
539            .collect()
540    };
541    let empty = filtered.is_empty();
542    if let Some(s) = st.completion.session.as_mut() {
543        s.filtered = filtered;
544    }
545
546    if empty {
547        return if was_open {
548            Decision::Dismiss
549        } else {
550            Decision::Idle
551        };
552    }
553
554    // Keep the selection in range; a fresh word restarts at the top. `set` is
555    // unconditional, so the panel (bound at Rebuild) re-renders even when the
556    // index is unchanged but the filtered set is not.
557    let len = st.completion.filtered_len();
558    let sel = if fresh_word {
559        0
560    } else {
561        st.completion.selected.get().min(len - 1)
562    };
563    st.completion.selected.set(sel);
564
565    if was_open {
566        Decision::Update
567    } else {
568        // Anchor at the START of the word so the popup stays put while typing.
569        // Before a layout there is no rect; the next keystroke retries (react
570        // runs per key), so this self-heals rather than sticking.
571        match super::keyboard::window_rect_at(st, word_start) {
572            Some(r) => Decision::Open(Point::new(r.x, r.y + r.height)),
573            None => Decision::Idle,
574        }
575    }
576}
577
578/// Show the popup (or, if somehow already shown, leave it) at `anchor`.
579fn open_or_update(state: &SharedState, ctx: &mut EventContext, anchor: Point) {
580    let (panel_id, self_id, open_sig) = {
581        let st = state.borrow();
582        (
583            st.completion.panel_id,
584            st.self_id,
585            st.completion.open.clone(),
586        )
587    };
588    let (Some(panel_id), Some(self_id)) = (panel_id, self_id) else {
589        return;
590    };
591    open_sig.set_if_changed(true);
592    // Build the panel if this is its first open, before the overlay below is
593    // measured against it.
594    ctx.materialize_now(panel_id);
595    ctx.activate(panel_id);
596
597    let on_dismiss: OverlayDismissCallback = {
598        let open = open_sig.clone();
599        Rc::new(move |_, _| {
600            if open.get() {
601                open.set(false);
602            }
603        })
604    };
605    ctx.show_overlay(
606        OverlayRequest::new(
607            panel_id,
608            self_id,
609            OverlayPlacement::AtPointer(anchor),
610            DismissBehavior::ClickOutside,
611        )
612        .on_dismiss(on_dismiss),
613    );
614    ctx.request_frame();
615}
616
617/// Close the popup and forget the session. Idempotent, and safe to call after a
618/// framework-driven dismissal (click-outside), which flips `open` via the
619/// `on_dismiss` callback but leaves the session — so the session is cleared here
620/// **unconditionally**, and the `open` signal is set outside the borrow so its
621/// `visible_when` fan-out cannot re-enter.
622pub(super) fn close(state: &SharedState, ctx: &mut EventContext) {
623    let (was_open, panel_id, open_sig) = {
624        let mut st = state.borrow_mut();
625        st.completion.session = None;
626        (
627            st.completion.open.get(),
628            st.completion.panel_id,
629            st.completion.open.clone(),
630        )
631    };
632    open_sig.set_if_changed(false);
633    if was_open && let Some(pid) = panel_id {
634        ctx.dismiss_overlay_by_content(pid);
635    }
636    ctx.request_frame();
637}
638
639// ─────────────────────────────────────────────────────────────────────────
640// Keyboard navigation (called from keyboard.rs while the popup is open)
641// ─────────────────────────────────────────────────────────────────────────
642
643/// Move the highlighted row by `delta`, wrapping. Repaints via the selection
644/// signal (the panel rebuilds its window around it).
645pub(super) fn move_selection(state: &SharedState, delta: i32) {
646    let st = state.borrow();
647    let len = st.completion.filtered_len();
648    if len == 0 {
649        return;
650    }
651    let cur = st.completion.selected.get() as i32;
652    let next = cur + delta;
653    let wrapped = next.rem_euclid(len as i32) as usize;
654    st.completion.selected.set(wrapped);
655}
656
657/// Accept the currently-highlighted candidate.
658pub(super) fn accept_selected(state: &SharedState, ctx: &mut EventContext) {
659    let sel = state.borrow().completion.selected.get();
660    commit(state, ctx, sel);
661}
662
663/// Accept a candidate by filtered index (also the mouse-click path).
664///
665/// Re-validates against the *live* caret before applying: a popup can go stale
666/// between opening and accepting (a Ctrl-chord that changed the document or
667/// selection while it lingered), and blindly replacing `[session.word_start,
668/// caret]` could delete from the old word to wherever the caret now is — up to
669/// the end of the document under a select-all. So the accept only proceeds when
670/// the caret is still on the session's word with no selection, and it replaces
671/// the **whole** identifier there (start to end), not merely up to the caret.
672pub(super) fn commit(state: &SharedState, ctx: &mut EventContext, filtered_index: usize) {
673    let accepted = state.borrow().completion.item_at(filtered_index);
674    let Some((session_word_start, insert)) = accepted else {
675        close(state, ctx);
676        return;
677    };
678    let span = {
679        let st = state.borrow();
680        if st.cursor.has_selection() {
681            None
682        } else {
683            let pos = st.cursor.position();
684            let (word_start, _) = semantics::word_prefix_before_caret(&st, pos);
685            if word_start != session_word_start {
686                None // the caret left the completing word — do not apply
687            } else {
688                Some((word_start, semantics::identifier_end(&st, pos)))
689            }
690        }
691    };
692    if let Some((start, end)) = span {
693        let mut st = state.borrow_mut();
694        semantics::accept_completion(&mut st, start, end, &insert);
695        st.pending_text_changed = true;
696    }
697    close(state, ctx);
698    sync_cursor_signals(state);
699    super::keyboard::ensure_caret_visible(state);
700    ctx.request_frame();
701}
702
703/// Escape: close the popup and suppress reopening for the current word.
704pub(super) fn dismiss_suppress(state: &SharedState, ctx: &mut EventContext) {
705    {
706        let mut st = state.borrow_mut();
707        let pos = st.cursor.position();
708        let (word_start, _) = semantics::word_prefix_before_caret(&st, pos);
709        st.completion.suppressed_at = Some(word_start);
710    }
711    close(state, ctx);
712}
713
714// ─────────────────────────────────────────────────────────────────────────
715// The popup widget
716// ─────────────────────────────────────────────────────────────────────────
717
718/// The presentational suggestion list — reads the live session from the shared
719/// state, rebuilds when the selection signal changes (set on every (re)filter as
720/// well as every arrow move), and commits a row on tap. It holds no completion
721/// logic of its own.
722pub(super) struct CompletionPanel {
723    state: SharedState,
724    root: Option<WidgetId>,
725}
726
727impl std::fmt::Debug for CompletionPanel {
728    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
729        f.debug_struct("CompletionPanel").finish_non_exhaustive()
730    }
731}
732
733impl CompletionPanel {
734    pub(super) fn new(state: &SharedState) -> Self {
735        Self {
736            state: state.clone(),
737            root: None,
738        }
739    }
740}
741
742impl Widget for CompletionPanel {
743    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
744        use crate::primitives::{
745            HStack, MinSize, Padding, RectWidget, Spacer, TextWidget, VStack, ZStack,
746        };
747        use teksilo_core::binding::BindingLevel;
748        use teksilo_i18n::lit;
749        use teksilo_tokens::CornerRadius;
750
751        let self_id = ctx.self_id();
752        let registry = ctx.binding_registry();
753        {
754            // The selection signal is set on every (re)filter and every arrow
755            // move, so a single Rebuild binding covers both the row set changing
756            // and the highlight moving — no separate version signal is needed.
757            let st = self.state.borrow();
758            st.completion
759                .selected
760                .bind_to(self_id, registry, BindingLevel::Rebuild);
761        }
762
763        let (total, selected) = {
764            let st = self.state.borrow();
765            (st.completion.filtered_len(), st.completion.selected.get())
766        };
767        if total == 0 {
768            self.state.borrow().completion.active_row.set(None);
769            self.root = None;
770            return Vec::new();
771        }
772
773        // Window the rows around the selection, and clone only that window.
774        let m = MAX_VISIBLE_ROWS.min(total);
775        let mut start = 0usize;
776        if selected >= m {
777            start = selected - m + 1;
778        }
779        if start > total - m {
780            start = total - m;
781        }
782        let end = start + m;
783        let items = self.state.borrow().completion.window_items(start, end);
784
785        // A suggestion row is a menu row, and takes the same target floor: 24 dp
786        // at Compact, 32 at Comfortable, 44 at Touch.
787        //
788        // **This can raise the row at Compact**, by the up-to-2 dp between the
789        // padded text line's own height (22 dp as measured headlessly under the
790        // shipped typography; a taller line may already clear the floor) and the
791        // conformance floor, and that is the
792        // documented exception to the density rule rather than a breach of it: a
793        // `MinSize` *is* a hit box, 24 dp is the floor that governs hit boxes,
794        // and a stack of adjacent rows is the one shape the hit mechanisms
795        // cannot serve — an outset on each row only moves the boundaries
796        // between them, because the neighbour it would borrow from is another
797        // row. See `engineering/docs/density-inventory.md` §0.
798        let row_height = teksilo_core::styles::density::density_min_size(
799            teksilo_canvas::Size::new(0.0, 0.0),
800            teksilo_tokens::TargetAxes::HEIGHT,
801            &ctx.theme().input,
802        )
803        .height;
804        let mut rows = VStack::new().spacing(1.0);
805        let mut active_row = None;
806        for (local, item) in items.iter().enumerate() {
807            let i = start + local;
808            let highlighted = i == selected;
809
810            let badge = TextWidget::new(lit!(item.kind.badge()))
811                .style(TextStyleRole::Small)
812                .color(TextRole::Secondary);
813            let label = TextWidget::new(lit!(item.label.clone())).style(TextStyleRole::Body);
814            let mut line = HStack::new()
815                .spacing(6.0)
816                .child(badge)
817                .child(label)
818                .child(Spacer::new());
819            if let Some(detail) = &item.detail {
820                line = line.child(
821                    TextWidget::new(lit!(detail.clone()))
822                        .style(TextStyleRole::Small)
823                        .color(TextRole::Secondary),
824                );
825            }
826
827            let row_state = self.state.clone();
828            let filtered_index = i;
829            let posinset = i + 1;
830            // Only the highlighted row paints a background; the rest are bare, so
831            // no "transparent" role is needed.
832            let mut row = ZStack::new();
833            if highlighted {
834                row = row.child(
835                    RectWidget::new()
836                        .background(SurfaceRole::Selected)
837                        .corner_radius(CornerRadius::uniform(4.0)),
838                );
839            }
840            let row = row
841                .child(
842                    MinSize::new(0.0, row_height).child(Padding::symmetric(3.0, 8.0).child(line)),
843                )
844                .on_tap(move |_event, ctx| {
845                    commit(&row_state, ctx, filtered_index);
846                })
847                .access_role(Role::ListBoxOption)
848                .access_customize(move |b| {
849                    b.inner_mut().set_selected(highlighted);
850                    b.set_position_in_set(posinset);
851                    // The "of N" half lives on the panel's `Role::ListBox`.
852                });
853            let id = ctx.add(row);
854            if highlighted {
855                active_row = Some(id);
856            }
857            rows = rows.child(id);
858        }
859        self.state.borrow().completion.active_row.set(active_row);
860
861        // A themed container: raised surface, hairline border, rounded.
862        let container = ZStack::new()
863            .child(
864                RectWidget::new()
865                    .background(SurfaceRole::Raised)
866                    .border_color(teksilo_tokens::BorderRole::Default)
867                    .border_width(1.0)
868                    .corner_radius(CornerRadius::uniform(6.0)),
869            )
870            .child(Padding::symmetric(4.0, 4.0).child(rows));
871        let container_id = ctx.add(container);
872        self.root = Some(container_id);
873        vec![container_id]
874    }
875
876    fn layout_response(
877        &self,
878        proposal: teksilo_canvas::SizeProposal,
879        ctx: &LayoutContext,
880    ) -> LayoutResponse {
881        // Size to the container's content — the popup is intrinsic, not greedy.
882        self.root
883            .and_then(|id| ctx.child_size(id, proposal))
884            .unwrap_or_else(|| teksilo_canvas::Size::new(0.0, 0.0))
885            .into()
886    }
887
888    fn place_children(
889        &self,
890        bounds: teksilo_canvas::Rect,
891        _proposal: teksilo_canvas::SizeProposal,
892        children: &mut [teksilo_core::widget::WidgetPlacement],
893        _ctx: &LayoutContext,
894    ) {
895        if let Some(child) = children.first_mut() {
896            child.origin = Point::new(bounds.x, bounds.y);
897            child.size = teksilo_canvas::Size::new(bounds.width, bounds.height);
898        }
899    }
900
901    fn children(&self) -> Vec<WidgetId> {
902        self.root.into_iter().collect()
903    }
904
905    fn accessibility(&self, builder: &mut teksilo_core::accessibility::AccessNodeBuilder) {
906        builder.set_role(Role::ListBox);
907        // The candidate count, on the container. AccessKit's `size_of_set`
908        // differs from ARIA's per-item `aria-setsize`, and
909        // `size_of_set_from_container` resolves an item's set size by walking
910        // *up* from it, so a count written on a row is read by no adapter.
911        //
912        // The logical count, not the realized window: `build` shows at most
913        // `MAX_VISIBLE_ROWS`, and a user arrowing through twenty candidates
914        // needs to hear twenty.
915        let total = self.state.borrow().completion.filtered_len();
916        if total > 0 {
917            builder.set_size_of_set(total);
918        }
919    }
920
921    fn clips_children(&self) -> bool {
922        true
923    }
924}