use super::*;
pub(super) fn refresh_autocomplete(state: &Arc<TuiState>, editor: &Arc<Editor>) {
let text = editor.get_text();
let cursor = editor_cursor_offset(editor, &text);
let suggestions = state.autocomplete.get_suggestions(&text, cursor);
render_autocomplete(state, suggestions);
}
pub(super) fn editor_cursor_offset(editor: &Editor, text: &str) -> usize {
let (row, col) = editor.cursor_position();
let mut offset = 0;
for (index, line) in text.split('\n').enumerate() {
if index == row {
return (offset + col.min(line.len())).min(text.len());
}
offset = offset.saturating_add(line.len() + 1);
}
text.len()
}
pub(super) fn set_editor_cursor_offset(editor: &Editor, text: &str, offset: usize) {
let offset = offset.min(text.len());
let mut consumed = 0;
for (row, line) in text.split('\n').enumerate() {
let end = consumed + line.len();
if offset <= end {
editor.set_cursor(row, offset - consumed);
return;
}
consumed = end + 1;
}
let last_row = text.bytes().filter(|byte| *byte == b'\n').count();
editor.set_cursor(
last_row,
text.rsplit('\n').next().map(str::len).unwrap_or(0),
);
}
fn stamp_selection_for(state: &Arc<TuiState>, key: Option<&str>) {
let mut stamp = state.autocomplete_selection_stamped_for.lock().unwrap();
match (key, stamp.as_ref()) {
(Some(current), Some(previous)) if current == previous => {}
_ => *stamp = None,
}
}
fn sugg_key(sugg: &AutocompleteSuggestions) -> String {
let items = sugg
.items
.iter()
.map(|item| item.text.as_str())
.collect::<Vec<_>>()
.join("\u{1}");
format!("{}\u{2}{}\u{2}{}", sugg.start, sugg.end, items)
}
fn selection_is_explicit(state: &Arc<TuiState>, sugg: &AutocompleteSuggestions) -> bool {
let key = sugg_key(sugg);
state
.autocomplete_selection_stamped_for
.lock()
.unwrap()
.as_deref()
== Some(key.as_str())
}
pub(super) fn render_autocomplete(
state: &Arc<TuiState>,
suggestions: Option<AutocompleteSuggestions>,
) {
state.autocomplete_container.clear();
let Some(sugg) = suggestions else {
*state.autocomplete_selection.lock().unwrap() = 0;
stamp_selection_for(state, None);
return;
};
if sugg.items.is_empty() {
*state.autocomplete_selection.lock().unwrap() = 0;
stamp_selection_for(state, None);
return;
}
let max_visible = state.autocomplete_max_visible;
let total = sugg.items.len();
let selection = {
let mut sel = state.autocomplete_selection.lock().unwrap();
*sel = (*sel).min(total.saturating_sub(1));
*sel
};
let window_start = if total <= max_visible {
0
} else {
selection
.saturating_sub(max_visible / 2)
.min(total - max_visible)
};
let window_end = (window_start + max_visible).min(total);
stamp_selection_for(state, Some(&sugg_key(&sugg)));
let accent = state.theme_manager.get().colors.accent;
let muted = state.theme_manager.get().colors.muted;
for (i, item) in sugg
.items
.iter()
.enumerate()
.skip(window_start)
.take(window_end - window_start)
{
let prefix = if i == selection { "→ " } else { " " };
let label = item.display_text();
let line = if i == selection {
format!(
"{prefix}{} {}",
accent.fg(label),
muted.fg(item.description.as_deref().unwrap_or(""))
)
} else {
format!(
"{prefix}{} {}",
muted.fg(label),
muted.fg(item.description.as_deref().unwrap_or(""))
)
};
state
.autocomplete_container
.add_child(Arc::new(Text::new(line, 1, 0)));
}
}
pub(super) fn accept_top_suggestion(state: &Arc<TuiState>, editor: &Arc<Editor>) -> bool {
let text = editor.get_text();
let cursor = editor_cursor_offset(editor, &text);
let Some(sugg) = state.autocomplete.get_suggestions(&text, cursor) else {
return false;
};
let index = {
let sel = *state.autocomplete_selection.lock().unwrap();
sel.min(sugg.items.len().saturating_sub(1))
};
let Some(top) = sugg.items.get(index) else {
return false;
};
let start = sugg.start.min(text.len());
let end = sugg.end.min(text.len());
if sugg.is_path {
let (segment_start, segment_end) = path_segment_bounds(&text, start, cursor);
let completes_whole = state
.autocomplete
.get_suggestions(&text, segment_end)
.is_some_and(|whole| whole.items.iter().any(|item| item.text == top.text));
if !completes_whole {
return false;
}
return apply_suggestion(state, editor, segment_start, segment_end, top);
}
apply_suggestion(state, editor, start, end, top)
}
fn apply_suggestion(
state: &Arc<TuiState>,
editor: &Arc<Editor>,
start: usize,
end: usize,
item: &AutocompleteItem,
) -> bool {
let text = editor.get_text();
let start = start.min(text.len());
let end = end.min(text.len());
let mut replaced = String::with_capacity(text.len() + item.text.len());
replaced.push_str(&text[..start]);
replaced.push_str(&item.text);
replaced.push_str(&text[end..]);
if item.insert_space && !replaced.ends_with('/') {
replaced.push(' ');
}
let new_cursor = replaced.len().min(
start
+ item.text.len()
+ if item.insert_space && !item.text.ends_with('/') {
1
} else {
0
},
);
editor.set_text(&replaced);
set_editor_cursor_offset(editor, &replaced, new_cursor);
state.autocomplete_container.clear();
*state.autocomplete_selection.lock().unwrap() = 0;
true
}
fn command_token_end(text: &str, cursor: usize) -> usize {
let cursor = snap_to_char_boundary(text, cursor);
let rest = &text[cursor..];
let offset = rest.find(char::is_whitespace);
cursor + offset.unwrap_or(rest.len())
}
fn snap_to_char_boundary(text: &str, cursor: usize) -> usize {
let cursor = cursor.min(text.len());
if text.is_char_boundary(cursor) {
return cursor;
}
text.char_indices()
.take_while(|(byte, _)| *byte < cursor)
.last()
.map(|(byte, _)| byte)
.unwrap_or(0)
}
fn path_segment_bounds(text: &str, token_start: usize, cursor: usize) -> (usize, usize) {
let cursor = snap_to_char_boundary(text, cursor);
let token_start = token_start.min(cursor);
let before_caret = &text[token_start..cursor];
let start = before_caret
.rfind(['/', '\\'])
.map(|offset| token_start + offset + 1)
.unwrap_or(token_start);
let after_caret = &text[cursor..];
let end = after_caret
.find(|c: char| c == '/' || c == '\\' || c.is_whitespace())
.map(|offset| cursor + offset)
.unwrap_or(text.len());
(start, end)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum EnterCompletionAction {
ApplyAndSubmit,
ApplyOnly,
Submit,
}
pub(super) struct EnterCompletion {
pub(super) action: EnterCompletionAction,
pub(super) candidate: Option<(AutocompleteItem, usize, usize)>,
}
impl EnterCompletion {
pub(super) fn apply(&self, state: &Arc<TuiState>, editor: &Arc<Editor>) -> bool {
let Some((item, start, end)) = self.candidate.as_ref() else {
return false;
};
apply_suggestion(state, editor, *start, *end, item)
}
pub(super) fn consumes_enter(&self) -> bool {
self.action == EnterCompletionAction::ApplyOnly
}
}
pub(super) fn resolve_enter_completion(
state: &Arc<TuiState>,
editor: &Arc<Editor>,
) -> Option<EnterCompletion> {
let text = editor.get_text();
let cursor = editor_cursor_offset(editor, &text);
let popup = state.autocomplete.get_suggestions(&text, cursor)?;
if popup.items.is_empty() {
return None;
}
let start = popup.start.min(text.len());
let explicit = text[start..].starts_with('/') || text[..start].ends_with('@');
if !explicit {
return None;
}
let command_end = text.find(char::is_whitespace).unwrap_or(text.len());
if !popup.is_path && cursor > command_end {
return None;
}
let explicit_selection = selection_is_explicit(state, &popup);
let index = {
let sel = *state.autocomplete_selection.lock().unwrap();
sel.min(popup.items.len().saturating_sub(1))
};
let Some(top) = popup.items.get(index) else {
return None;
};
if popup.is_path && popup.items.len() > 1 && !explicit_selection {
return Some(EnterCompletion {
action: EnterCompletionAction::Submit,
candidate: None,
});
}
let (replacement_start, replacement_end, completes_whole) = if popup.is_path {
let (segment_start, segment_end) = path_segment_bounds(&text, start, cursor);
let whole = state
.autocomplete
.get_suggestions(&text, segment_end)
.is_some_and(|whole| whole.items.iter().any(|item| item.text == top.text));
(segment_start, segment_end, whole)
} else {
let token_end = command_token_end(&text, cursor).min(text.len());
let whole = state
.autocomplete
.get_suggestions(&text, token_end)
.is_some_and(|whole| whole.items.iter().any(|item| item.text == top.text));
(start, token_end, whole)
};
if !completes_whole && !explicit_selection {
return Some(EnterCompletion {
action: EnterCompletionAction::Submit,
candidate: None,
});
}
Some(EnterCompletion {
action: if popup.is_path {
EnterCompletionAction::ApplyOnly
} else {
EnterCompletionAction::ApplyAndSubmit
},
candidate: Some((top.clone(), replacement_start, replacement_end)),
})
}
pub(super) fn route_enter_completion(state: &Arc<TuiState>, editor: &Arc<Editor>) -> bool {
let Some(completion) = resolve_enter_completion(state, editor) else {
return false;
};
completion.apply(state, editor);
completion.consumes_enter()
}
#[cfg(test)]
pub(super) fn enter_completion_action(
state: &Arc<TuiState>,
editor: &Arc<Editor>,
) -> Option<EnterCompletionAction> {
resolve_enter_completion(state, editor).map(|completion| completion.action)
}
pub(super) fn navigate_autocomplete(
state: &Arc<TuiState>,
editor: &Arc<Editor>,
direction: i32,
) -> bool {
let text = editor.get_text();
let cursor = editor_cursor_offset(editor, &text);
let Some(sugg) = state.autocomplete.get_suggestions(&text, cursor) else {
return false;
};
if sugg.items.is_empty() {
return false;
}
let start = sugg.start.min(text.len());
let explicit = text[start..].starts_with('/') || text[..start].ends_with('@');
if !explicit {
return false;
}
let total = sugg.items.len();
{
let mut sel = state.autocomplete_selection.lock().unwrap();
if direction < 0 {
*sel = sel.saturating_sub(1);
} else {
*sel = (*sel + 1).min(total.saturating_sub(1));
}
}
*state.autocomplete_selection_stamped_for.lock().unwrap() = Some(sugg_key(&sugg));
render_autocomplete(state, Some(sugg));
true
}