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