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, OverlayLayer, 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(OverlayRequest {
606        content_id: panel_id,
607        anchor: self_id,
608        placement: OverlayPlacement::AtPointer(anchor),
609        dismiss: DismissBehavior::ClickOutside,
610        layer: OverlayLayer::InTree,
611        parent_overlay: None,
612        on_dismiss: Some(on_dismiss),
613        fade_duration: None,
614    });
615    ctx.request_frame();
616}
617
618/// Close the popup and forget the session. Idempotent, and safe to call after a
619/// framework-driven dismissal (click-outside), which flips `open` via the
620/// `on_dismiss` callback but leaves the session — so the session is cleared here
621/// **unconditionally**, and the `open` signal is set outside the borrow so its
622/// `visible_when` fan-out cannot re-enter.
623pub(super) fn close(state: &SharedState, ctx: &mut EventContext) {
624    let (was_open, panel_id, open_sig) = {
625        let mut st = state.borrow_mut();
626        st.completion.session = None;
627        (
628            st.completion.open.get(),
629            st.completion.panel_id,
630            st.completion.open.clone(),
631        )
632    };
633    open_sig.set_if_changed(false);
634    if was_open && let Some(pid) = panel_id {
635        ctx.dismiss_overlay_by_content(pid);
636    }
637    ctx.request_frame();
638}
639
640// ─────────────────────────────────────────────────────────────────────────
641// Keyboard navigation (called from keyboard.rs while the popup is open)
642// ─────────────────────────────────────────────────────────────────────────
643
644/// Move the highlighted row by `delta`, wrapping. Repaints via the selection
645/// signal (the panel rebuilds its window around it).
646pub(super) fn move_selection(state: &SharedState, delta: i32) {
647    let st = state.borrow();
648    let len = st.completion.filtered_len();
649    if len == 0 {
650        return;
651    }
652    let cur = st.completion.selected.get() as i32;
653    let next = cur + delta;
654    let wrapped = next.rem_euclid(len as i32) as usize;
655    st.completion.selected.set(wrapped);
656}
657
658/// Accept the currently-highlighted candidate.
659pub(super) fn accept_selected(state: &SharedState, ctx: &mut EventContext) {
660    let sel = state.borrow().completion.selected.get();
661    commit(state, ctx, sel);
662}
663
664/// Accept a candidate by filtered index (also the mouse-click path).
665///
666/// Re-validates against the *live* caret before applying: a popup can go stale
667/// between opening and accepting (a Ctrl-chord that changed the document or
668/// selection while it lingered), and blindly replacing `[session.word_start,
669/// caret]` could delete from the old word to wherever the caret now is — up to
670/// the end of the document under a select-all. So the accept only proceeds when
671/// the caret is still on the session's word with no selection, and it replaces
672/// the **whole** identifier there (start to end), not merely up to the caret.
673pub(super) fn commit(state: &SharedState, ctx: &mut EventContext, filtered_index: usize) {
674    let accepted = state.borrow().completion.item_at(filtered_index);
675    let Some((session_word_start, insert)) = accepted else {
676        close(state, ctx);
677        return;
678    };
679    let span = {
680        let st = state.borrow();
681        if st.cursor.has_selection() {
682            None
683        } else {
684            let pos = st.cursor.position();
685            let (word_start, _) = semantics::word_prefix_before_caret(&st, pos);
686            if word_start != session_word_start {
687                None // the caret left the completing word — do not apply
688            } else {
689                Some((word_start, semantics::identifier_end(&st, pos)))
690            }
691        }
692    };
693    if let Some((start, end)) = span {
694        let mut st = state.borrow_mut();
695        semantics::accept_completion(&mut st, start, end, &insert);
696        st.pending_text_changed = true;
697    }
698    close(state, ctx);
699    sync_cursor_signals(state);
700    super::keyboard::ensure_caret_visible(state);
701    ctx.request_frame();
702}
703
704/// Escape: close the popup and suppress reopening for the current word.
705pub(super) fn dismiss_suppress(state: &SharedState, ctx: &mut EventContext) {
706    {
707        let mut st = state.borrow_mut();
708        let pos = st.cursor.position();
709        let (word_start, _) = semantics::word_prefix_before_caret(&st, pos);
710        st.completion.suppressed_at = Some(word_start);
711    }
712    close(state, ctx);
713}
714
715// ─────────────────────────────────────────────────────────────────────────
716// The popup widget
717// ─────────────────────────────────────────────────────────────────────────
718
719/// The presentational suggestion list — reads the live session from the shared
720/// state, rebuilds when the selection signal changes (set on every (re)filter as
721/// well as every arrow move), and commits a row on tap. It holds no completion
722/// logic of its own.
723pub(super) struct CompletionPanel {
724    state: SharedState,
725    root: Option<WidgetId>,
726}
727
728impl std::fmt::Debug for CompletionPanel {
729    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
730        f.debug_struct("CompletionPanel").finish_non_exhaustive()
731    }
732}
733
734impl CompletionPanel {
735    pub(super) fn new(state: &SharedState) -> Self {
736        Self {
737            state: state.clone(),
738            root: None,
739        }
740    }
741}
742
743impl Widget for CompletionPanel {
744    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
745        use crate::primitives::{
746            HStack, MinSize, Padding, RectWidget, Spacer, TextWidget, VStack, ZStack,
747        };
748        use teksilo_core::binding::BindingLevel;
749        use teksilo_i18n::lit;
750        use teksilo_tokens::CornerRadius;
751
752        let self_id = ctx.self_id();
753        let registry = ctx.binding_registry();
754        {
755            // The selection signal is set on every (re)filter and every arrow
756            // move, so a single Rebuild binding covers both the row set changing
757            // and the highlight moving — no separate version signal is needed.
758            let st = self.state.borrow();
759            st.completion
760                .selected
761                .bind_to(self_id, registry, BindingLevel::Rebuild);
762        }
763
764        let (total, selected) = {
765            let st = self.state.borrow();
766            (st.completion.filtered_len(), st.completion.selected.get())
767        };
768        if total == 0 {
769            self.state.borrow().completion.active_row.set(None);
770            self.root = None;
771            return Vec::new();
772        }
773
774        // Window the rows around the selection, and clone only that window.
775        let m = MAX_VISIBLE_ROWS.min(total);
776        let mut start = 0usize;
777        if selected >= m {
778            start = selected - m + 1;
779        }
780        if start > total - m {
781            start = total - m;
782        }
783        let end = start + m;
784        let items = self.state.borrow().completion.window_items(start, end);
785
786        // A suggestion row is a menu row, and takes the same target floor: 24 dp
787        // at Compact, 32 at Comfortable, 44 at Touch.
788        //
789        // **This can raise the row at Compact**, by the up-to-2 dp between the
790        // padded text line's own height (22 dp as measured headlessly under the
791        // shipped typography; a taller line may already clear the floor) and the
792        // conformance floor, and that is the
793        // documented exception to the density rule rather than a breach of it: a
794        // `MinSize` *is* a hit box, 24 dp is the floor that governs hit boxes,
795        // and a stack of adjacent rows is the one shape the hit mechanisms
796        // cannot serve — an outset on each row only moves the boundaries
797        // between them, because the neighbour it would borrow from is another
798        // row. See `docs/density-inventory.md` §0.
799        let row_height = teksilo_core::styles::density::density_min_size(
800            teksilo_canvas::Size::new(0.0, 0.0),
801            teksilo_tokens::TargetAxes::HEIGHT,
802            &ctx.theme().input,
803        )
804        .height;
805        let mut rows = VStack::new().spacing(1.0);
806        let mut active_row = None;
807        for (local, item) in items.iter().enumerate() {
808            let i = start + local;
809            let highlighted = i == selected;
810
811            let badge = TextWidget::new(lit!(item.kind.badge()))
812                .style(TextStyleRole::Small)
813                .color(TextRole::Secondary);
814            let label = TextWidget::new(lit!(item.label.clone())).style(TextStyleRole::Body);
815            let mut line = HStack::new()
816                .spacing(6.0)
817                .child(badge)
818                .child(label)
819                .child(Spacer::new());
820            if let Some(detail) = &item.detail {
821                line = line.child(
822                    TextWidget::new(lit!(detail.clone()))
823                        .style(TextStyleRole::Small)
824                        .color(TextRole::Secondary),
825                );
826            }
827
828            let row_state = self.state.clone();
829            let filtered_index = i;
830            let posinset = i + 1;
831            // Only the highlighted row paints a background; the rest are bare, so
832            // no "transparent" role is needed.
833            let mut row = ZStack::new();
834            if highlighted {
835                row = row.child(
836                    RectWidget::new()
837                        .background(SurfaceRole::Selected)
838                        .corner_radius(CornerRadius::uniform(4.0)),
839                );
840            }
841            let row = row
842                .child(
843                    MinSize::new(0.0, row_height).child(Padding::symmetric(3.0, 8.0).child(line)),
844                )
845                .on_tap(move |_event, ctx| {
846                    commit(&row_state, ctx, filtered_index);
847                })
848                .access_role(Role::ListBoxOption)
849                .access_customize(move |b| {
850                    b.inner_mut().set_selected(highlighted);
851                    b.set_position_in_set(posinset);
852                    // The "of N" half lives on the panel's `Role::ListBox`.
853                });
854            let id = ctx.add(row);
855            if highlighted {
856                active_row = Some(id);
857            }
858            rows = rows.child(id);
859        }
860        self.state.borrow().completion.active_row.set(active_row);
861
862        // A themed container: raised surface, hairline border, rounded.
863        let container = ZStack::new()
864            .child(
865                RectWidget::new()
866                    .background(SurfaceRole::Raised)
867                    .border_color(teksilo_tokens::BorderRole::Default)
868                    .border_width(1.0)
869                    .corner_radius(CornerRadius::uniform(6.0)),
870            )
871            .child(Padding::symmetric(4.0, 4.0).child(rows));
872        let container_id = ctx.add(container);
873        self.root = Some(container_id);
874        vec![container_id]
875    }
876
877    fn layout_response(
878        &self,
879        proposal: teksilo_canvas::SizeProposal,
880        ctx: &LayoutContext,
881    ) -> LayoutResponse {
882        // Size to the container's content — the popup is intrinsic, not greedy.
883        self.root
884            .and_then(|id| ctx.child_size(id, proposal))
885            .unwrap_or_else(|| teksilo_canvas::Size::new(0.0, 0.0))
886            .into()
887    }
888
889    fn place_children(
890        &self,
891        bounds: teksilo_canvas::Rect,
892        _proposal: teksilo_canvas::SizeProposal,
893        children: &mut [teksilo_core::widget::WidgetPlacement],
894        _ctx: &LayoutContext,
895    ) {
896        if let Some(child) = children.first_mut() {
897            child.origin = Point::new(bounds.x, bounds.y);
898            child.size = teksilo_canvas::Size::new(bounds.width, bounds.height);
899        }
900    }
901
902    fn children(&self) -> Vec<WidgetId> {
903        self.root.into_iter().collect()
904    }
905
906    fn accessibility(&self, builder: &mut teksilo_core::accessibility::AccessNodeBuilder) {
907        builder.set_role(Role::ListBox);
908        // The candidate count, on the container. AccessKit's `size_of_set`
909        // differs from ARIA's per-item `aria-setsize`, and
910        // `size_of_set_from_container` resolves an item's set size by walking
911        // *up* from it, so a count written on a row is read by no adapter.
912        //
913        // The logical count, not the realized window: `build` shows at most
914        // `MAX_VISIBLE_ROWS`, and a user arrowing through twenty candidates
915        // needs to hear twenty.
916        let total = self.state.borrow().completion.filtered_len();
917        if total > 0 {
918            builder.set_size_of_set(total);
919        }
920    }
921
922    fn clips_children(&self) -> bool {
923        true
924    }
925}