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}