use std::cell::Cell;
use std::rc::Rc;
use teksilo_canvas::Point;
use teksilo_core::Signal;
use teksilo_core::accesskit::Role;
use teksilo_core::build_context::BuildContext;
use teksilo_core::overlay::{
DismissBehavior, OverlayDismissCallback, OverlayLayer, OverlayPlacement, OverlayRequest,
};
use teksilo_core::widget::{EventContext, LayoutContext, LayoutResponse, Widget};
use teksilo_core::widget_builder::WidgetBuilder;
use teksilo_core::widget_id::WidgetId;
use teksilo_tokens::{SurfaceRole, TextRole, TextStyleRole};
use super::state::{CodeEditorState, SharedState};
use super::{semantics, sync_cursor_signals};
const MAX_VISIBLE_ROWS: usize = 10;
#[derive(Debug, Clone)]
pub struct CompletionItem {
pub label: String,
pub insert_text: String,
pub detail: Option<String>,
pub kind: CompletionKind,
}
impl CompletionItem {
pub fn new(label: impl Into<String>) -> Self {
let label = label.into();
Self {
insert_text: label.clone(),
label,
detail: None,
kind: CompletionKind::Text,
}
}
pub fn insert_text(mut self, text: impl Into<String>) -> Self {
self.insert_text = text.into();
self
}
pub fn detail(mut self, detail: impl Into<String>) -> Self {
self.detail = Some(detail.into());
self
}
pub fn kind(mut self, kind: CompletionKind) -> Self {
self.kind = kind;
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CompletionKind {
Text,
Keyword,
Function,
Method,
Variable,
Field,
Type,
Module,
Constant,
Snippet,
}
impl CompletionKind {
fn badge(self) -> &'static str {
match self {
CompletionKind::Text => "a",
CompletionKind::Keyword => "k",
CompletionKind::Function => "ƒ",
CompletionKind::Method => "m",
CompletionKind::Variable => "v",
CompletionKind::Field => "•",
CompletionKind::Type => "T",
CompletionKind::Module => "☐",
CompletionKind::Constant => "c",
CompletionKind::Snippet => "▢",
}
}
}
pub struct CompletionContext<'a> {
pub prefix: &'a str,
pub line: &'a str,
pub column: usize,
pub position: usize,
}
pub(super) type Provider = Rc<dyn Fn(&CompletionContext) -> Vec<CompletionItem>>;
struct Session {
candidates: Vec<CompletionItem>,
filtered: Vec<usize>,
word_start: usize,
}
pub(crate) struct CompletionState {
pub(super) provider: Option<Provider>,
pub(super) auto_trigger: bool,
session: Option<Session>,
suppressed_at: Option<usize>,
pub(super) panel_id: Option<WidgetId>,
pub(super) active_row: Rc<Cell<Option<WidgetId>>>,
pub open: Signal<bool>,
pub selected: Signal<usize>,
}
impl CompletionState {
pub(super) fn new() -> Self {
Self {
provider: None,
auto_trigger: true,
session: None,
suppressed_at: None,
panel_id: None,
active_row: Rc::new(Cell::new(None)),
open: Signal::new(false),
selected: Signal::new(0),
}
}
pub(super) fn is_open(&self) -> bool {
self.open.get()
}
pub(super) fn has_provider(&self) -> bool {
self.provider.is_some()
}
#[cfg(test)]
fn visible_items(&self) -> Vec<CompletionItem> {
self.window_items(0, self.filtered_len())
}
fn window_items(&self, start: usize, end: usize) -> Vec<CompletionItem> {
match &self.session {
Some(s) => s.filtered[start.min(s.filtered.len())..end.min(s.filtered.len())]
.iter()
.map(|&i| s.candidates[i].clone())
.collect(),
None => Vec::new(),
}
}
fn filtered_len(&self) -> usize {
self.session.as_ref().map(|s| s.filtered.len()).unwrap_or(0)
}
fn item_at(&self, filtered_index: usize) -> Option<(usize, String)> {
let s = self.session.as_ref()?;
let cand = *s.filtered.get(filtered_index)?;
Some((s.word_start, s.candidates[cand].insert_text.clone()))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum Trigger {
Typed,
Edited,
Moved,
Forced,
}
pub(super) fn react(state: &SharedState, ctx: &mut EventContext, trigger: Trigger) {
if !state.borrow().completion.has_provider() {
return;
}
let flushed = {
let mut st = state.borrow_mut();
if st.pending_chars.is_empty() {
false
} else {
let batch = std::mem::take(&mut st.pending_chars);
super::frame_loop::insert_at_every_caret(&mut st, &batch);
true
}
};
if flushed {
sync_cursor_signals(state);
}
let fetched = {
let req = {
let st = state.borrow();
prepare_fetch(&st, trigger)
};
req.map(|r| {
let cx = CompletionContext {
prefix: &r.prefix,
line: &r.line,
column: r.column,
position: r.position,
};
(r.word_start, (r.provider)(&cx))
})
};
let decision = {
let mut st = state.borrow_mut();
evaluate(&mut st, trigger, fetched)
};
match decision {
Decision::Open(anchor) => open_or_update(state, ctx, anchor),
Decision::Update => {}
Decision::Dismiss => close(state, ctx),
Decision::Idle => {}
}
}
enum Decision {
Open(Point),
Update,
Dismiss,
Idle,
}
struct FetchReq {
word_start: usize,
prefix: String,
line: String,
column: usize,
position: usize,
provider: Provider,
}
fn prepare_fetch(st: &CodeEditorState, trigger: Trigger) -> Option<FetchReq> {
if st.cursor.has_selection() || !st.extra_carets.is_empty() {
return None;
}
let was_open = st.completion.open.get();
let may_open = matches!(trigger, Trigger::Forced)
|| (trigger == Trigger::Typed && st.completion.auto_trigger);
if !was_open && !may_open {
return None;
}
let pos = st.cursor.position();
let (word_start, prefix) = semantics::word_prefix_before_caret(st, pos);
if st.completion.suppressed_at == Some(word_start) && trigger != Trigger::Forced {
return None;
}
if prefix.is_empty() && trigger != Trigger::Forced {
return None;
}
let fresh = st
.completion
.session
.as_ref()
.map(|s| s.word_start != word_start)
.unwrap_or(true);
if !fresh {
return None;
}
let (line, column) = st
.document
.snapshot_block_at_position_without_highlights(pos)
.map(|b| (b.text, pos - b.position))
.unwrap_or_default();
Some(FetchReq {
word_start,
prefix,
line,
column,
position: pos,
provider: st.completion.provider.clone()?,
})
}
#[cfg(test)]
impl CompletionState {
pub(super) fn test_labels(&self) -> Vec<String> {
self.visible_items().into_iter().map(|i| i.label).collect()
}
pub(super) fn test_set_suppressed(&mut self, at: Option<usize>) {
self.suppressed_at = at;
}
}
#[cfg(test)]
pub(super) fn test_evaluate(state: &SharedState, trigger: Trigger) {
let fetched = {
let req = {
let st = state.borrow();
prepare_fetch(&st, trigger)
};
req.map(|r| {
let cx = CompletionContext {
prefix: &r.prefix,
line: &r.line,
column: r.column,
position: r.position,
};
(r.word_start, (r.provider)(&cx))
})
};
let mut st = state.borrow_mut();
let _ = evaluate(&mut st, trigger, fetched);
}
fn evaluate(
st: &mut CodeEditorState,
trigger: Trigger,
fetched: Option<(usize, Vec<CompletionItem>)>,
) -> Decision {
let was_open = st.completion.open.get();
if trigger == Trigger::Forced {
st.completion.suppressed_at = None;
}
if st.cursor.has_selection() || !st.extra_carets.is_empty() {
st.completion.session = None;
return if was_open {
Decision::Dismiss
} else {
Decision::Idle
};
}
let may_open = matches!(trigger, Trigger::Forced)
|| (trigger == Trigger::Typed && st.completion.auto_trigger);
if !was_open && !may_open {
return Decision::Idle;
}
let pos = st.cursor.position();
let (word_start, prefix) = semantics::word_prefix_before_caret(st, pos);
if st.completion.suppressed_at == Some(word_start) && trigger != Trigger::Forced {
return Decision::Idle;
}
if st.completion.suppressed_at.is_some() && st.completion.suppressed_at != Some(word_start) {
st.completion.suppressed_at = None;
}
if was_open
&& trigger == Trigger::Moved
&& st.completion.session.as_ref().map(|s| s.word_start) != Some(word_start)
{
st.completion.session = None;
return Decision::Dismiss;
}
if prefix.is_empty() && trigger != Trigger::Forced {
st.completion.session = None;
return if was_open {
Decision::Dismiss
} else {
Decision::Idle
};
}
let fresh_word = st
.completion
.session
.as_ref()
.map(|s| s.word_start != word_start)
.unwrap_or(true);
if fresh_word {
let candidates = match fetched {
Some((ws, cands)) if ws == word_start => cands,
_ => Vec::new(),
};
st.completion.session = Some(Session {
candidates,
filtered: Vec::new(),
word_start,
});
}
let lower = prefix.to_lowercase();
let filtered: Vec<usize> = {
let s = st.completion.session.as_ref().expect("session set above");
s.candidates
.iter()
.enumerate()
.filter(|(_, c)| lower.is_empty() || c.label.to_lowercase().starts_with(&lower))
.map(|(i, _)| i)
.collect()
};
let empty = filtered.is_empty();
if let Some(s) = st.completion.session.as_mut() {
s.filtered = filtered;
}
if empty {
return if was_open {
Decision::Dismiss
} else {
Decision::Idle
};
}
let len = st.completion.filtered_len();
let sel = if fresh_word {
0
} else {
st.completion.selected.get().min(len - 1)
};
st.completion.selected.set(sel);
if was_open {
Decision::Update
} else {
match super::keyboard::window_rect_at(st, word_start) {
Some(r) => Decision::Open(Point::new(r.x, r.y + r.height)),
None => Decision::Idle,
}
}
}
fn open_or_update(state: &SharedState, ctx: &mut EventContext, anchor: Point) {
let (panel_id, self_id, open_sig) = {
let st = state.borrow();
(
st.completion.panel_id,
st.self_id,
st.completion.open.clone(),
)
};
let (Some(panel_id), Some(self_id)) = (panel_id, self_id) else {
return;
};
open_sig.set_if_changed(true);
ctx.materialize_now(panel_id);
ctx.activate(panel_id);
let on_dismiss: OverlayDismissCallback = {
let open = open_sig.clone();
Rc::new(move || {
if open.get() {
open.set(false);
}
})
};
ctx.show_overlay(OverlayRequest {
content_id: panel_id,
anchor: self_id,
placement: OverlayPlacement::AtPointer(anchor),
dismiss: DismissBehavior::ClickOutside,
layer: OverlayLayer::InTree,
parent_overlay: None,
on_dismiss: Some(on_dismiss),
fade_duration: None,
});
ctx.request_frame();
}
pub(super) fn close(state: &SharedState, ctx: &mut EventContext) {
let (was_open, panel_id, open_sig) = {
let mut st = state.borrow_mut();
st.completion.session = None;
(
st.completion.open.get(),
st.completion.panel_id,
st.completion.open.clone(),
)
};
open_sig.set_if_changed(false);
if was_open && let Some(pid) = panel_id {
ctx.dismiss_overlay_by_content(pid);
}
ctx.request_frame();
}
pub(super) fn move_selection(state: &SharedState, delta: i32) {
let st = state.borrow();
let len = st.completion.filtered_len();
if len == 0 {
return;
}
let cur = st.completion.selected.get() as i32;
let next = cur + delta;
let wrapped = next.rem_euclid(len as i32) as usize;
st.completion.selected.set(wrapped);
}
pub(super) fn accept_selected(state: &SharedState, ctx: &mut EventContext) {
let sel = state.borrow().completion.selected.get();
commit(state, ctx, sel);
}
pub(super) fn commit(state: &SharedState, ctx: &mut EventContext, filtered_index: usize) {
let accepted = state.borrow().completion.item_at(filtered_index);
let Some((session_word_start, insert)) = accepted else {
close(state, ctx);
return;
};
let span = {
let st = state.borrow();
if st.cursor.has_selection() {
None
} else {
let pos = st.cursor.position();
let (word_start, _) = semantics::word_prefix_before_caret(&st, pos);
if word_start != session_word_start {
None } else {
Some((word_start, semantics::identifier_end(&st, pos)))
}
}
};
if let Some((start, end)) = span {
let mut st = state.borrow_mut();
semantics::accept_completion(&mut st, start, end, &insert);
st.pending_text_changed = true;
}
close(state, ctx);
sync_cursor_signals(state);
super::keyboard::ensure_caret_visible(state);
ctx.request_frame();
}
pub(super) fn dismiss_suppress(state: &SharedState, ctx: &mut EventContext) {
{
let mut st = state.borrow_mut();
let pos = st.cursor.position();
let (word_start, _) = semantics::word_prefix_before_caret(&st, pos);
st.completion.suppressed_at = Some(word_start);
}
close(state, ctx);
}
pub(super) struct CompletionPanel {
state: SharedState,
root: Option<WidgetId>,
}
impl std::fmt::Debug for CompletionPanel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CompletionPanel").finish_non_exhaustive()
}
}
impl CompletionPanel {
pub(super) fn new(state: &SharedState) -> Self {
Self {
state: state.clone(),
root: None,
}
}
}
impl Widget for CompletionPanel {
fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
use crate::primitives::{HStack, Padding, RectWidget, Spacer, TextWidget, VStack, ZStack};
use teksilo_core::binding::BindingLevel;
use teksilo_i18n::lit;
use teksilo_tokens::CornerRadius;
let self_id = ctx.self_id();
let registry = ctx.binding_registry();
{
let st = self.state.borrow();
st.completion
.selected
.bind_to(self_id, registry, BindingLevel::Rebuild);
}
let (total, selected) = {
let st = self.state.borrow();
(st.completion.filtered_len(), st.completion.selected.get())
};
if total == 0 {
self.state.borrow().completion.active_row.set(None);
self.root = None;
return Vec::new();
}
let m = MAX_VISIBLE_ROWS.min(total);
let mut start = 0usize;
if selected >= m {
start = selected - m + 1;
}
if start > total - m {
start = total - m;
}
let end = start + m;
let items = self.state.borrow().completion.window_items(start, end);
let mut rows = VStack::new().spacing(1.0);
let mut active_row = None;
for (local, item) in items.iter().enumerate() {
let i = start + local;
let highlighted = i == selected;
let badge = TextWidget::new(lit!(item.kind.badge()))
.style(TextStyleRole::Small)
.color(TextRole::Secondary);
let label = TextWidget::new(lit!(item.label.clone())).style(TextStyleRole::Body);
let mut line = HStack::new()
.spacing(6.0)
.child(badge)
.child(label)
.child(Spacer::new());
if let Some(detail) = &item.detail {
line = line.child(
TextWidget::new(lit!(detail.clone()))
.style(TextStyleRole::Small)
.color(TextRole::Secondary),
);
}
let row_state = self.state.clone();
let filtered_index = i;
let posinset = i + 1;
let mut row = ZStack::new();
if highlighted {
row = row.child(
RectWidget::new()
.background(SurfaceRole::Selected)
.corner_radius(CornerRadius::uniform(4.0)),
);
}
let row = row
.child(Padding::symmetric(3.0, 8.0).child(line))
.on_tap(move |_event, ctx| {
commit(&row_state, ctx, filtered_index);
})
.access_role(Role::ListBoxOption)
.access_customize(move |b| {
b.inner_mut().set_selected(highlighted);
b.inner_mut().set_position_in_set(posinset);
b.inner_mut().set_size_of_set(total);
});
let id = ctx.add(row);
if highlighted {
active_row = Some(id);
}
rows = rows.add_child(id);
}
self.state.borrow().completion.active_row.set(active_row);
let container = ZStack::new()
.child(
RectWidget::new()
.background(SurfaceRole::Raised)
.border_color(teksilo_tokens::BorderRole::Default)
.border_width(1.0)
.corner_radius(CornerRadius::uniform(6.0)),
)
.child(Padding::symmetric(4.0, 4.0).child(rows));
let container_id = ctx.add(container);
self.root = Some(container_id);
vec![container_id]
}
fn layout_response(
&self,
proposal: teksilo_canvas::SizeProposal,
ctx: &LayoutContext,
) -> LayoutResponse {
self.root
.and_then(|id| ctx.child_size(id, proposal))
.unwrap_or_else(|| teksilo_canvas::Size::new(0.0, 0.0))
.into()
}
fn place_children(
&self,
bounds: teksilo_canvas::Rect,
_proposal: teksilo_canvas::SizeProposal,
children: &mut [teksilo_core::widget::WidgetPlacement],
_ctx: &LayoutContext,
) {
if let Some(child) = children.first_mut() {
child.origin = Point::new(bounds.x, bounds.y);
child.size = teksilo_canvas::Size::new(bounds.width, bounds.height);
}
}
fn children(&self) -> Vec<WidgetId> {
self.root.into_iter().collect()
}
fn accessibility(&self, builder: &mut teksilo_core::accessibility::AccessNodeBuilder) {
builder.set_role(Role::ListBox);
}
fn clips_children(&self) -> bool {
true
}
}