1use 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
51const MAX_VISIBLE_ROWS: usize = 10;
54
55#[derive(Debug, Clone)]
62pub struct CompletionItem {
63 pub label: String,
65 pub insert_text: String,
68 pub detail: Option<String>,
71 pub kind: CompletionKind,
73}
74
75impl CompletionItem {
76 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 pub fn insert_text(mut self, text: impl Into<String>) -> Self {
89 self.insert_text = text.into();
90 self
91 }
92
93 pub fn detail(mut self, detail: impl Into<String>) -> Self {
95 self.detail = Some(detail.into());
96 self
97 }
98
99 pub fn kind(mut self, kind: CompletionKind) -> Self {
101 self.kind = kind;
102 self
103 }
104}
105
106#[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 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
142pub struct CompletionContext<'a> {
144 pub prefix: &'a str,
146 pub line: &'a str,
148 pub column: usize,
150 pub position: usize,
152}
153
154pub(super) type Provider = Rc<dyn Fn(&CompletionContext) -> Vec<CompletionItem>>;
156
157struct Session {
164 candidates: Vec<CompletionItem>,
165 filtered: Vec<usize>,
166 word_start: usize,
169}
170
171pub(crate) struct CompletionState {
173 pub(super) provider: Option<Provider>,
174 pub(super) auto_trigger: bool,
176
177 session: Option<Session>,
178 suppressed_at: Option<usize>,
181
182 pub(super) panel_id: Option<WidgetId>,
184 pub(super) active_row: Rc<Cell<Option<WidgetId>>>,
188
189 pub open: Signal<bool>,
192 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 #[cfg(test)]
224 fn visible_items(&self) -> Vec<CompletionItem> {
225 self.window_items(0, self.filtered_len())
226 }
227
228 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 fn filtered_len(&self) -> usize {
242 self.session.as_ref().map(|s| s.filtered.len()).unwrap_or(0)
243 }
244
245 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
261pub(super) enum Trigger {
262 Typed,
264 Edited,
266 Moved,
268 Forced,
270}
271
272pub(super) fn react(state: &SharedState, ctx: &mut EventContext, trigger: Trigger) {
276 if !state.borrow().completion.has_provider() {
277 return;
278 }
279 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 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 Open(Point),
333 Update,
335 Dismiss,
337 Idle,
339}
340
341struct FetchReq {
344 word_start: usize,
345 prefix: String,
346 line: String,
347 column: usize,
348 position: usize,
349 provider: Provider,
350}
351
352fn prepare_fetch(st: &CodeEditorState, trigger: Trigger) -> Option<FetchReq> {
356 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 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#[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
433fn 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 if trigger == Trigger::Forced {
447 st.completion.suppressed_at = None;
448 }
449
450 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 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 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 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 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 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 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 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
569fn 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 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
609pub(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
631pub(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
649pub(super) fn accept_selected(state: &SharedState, ctx: &mut EventContext) {
651 let sel = state.borrow().completion.selected.get();
652 commit(state, ctx, sel);
653}
654
655pub(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 } 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
695pub(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
706pub(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 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 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 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 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 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}