use core::ops::Range;
use emath::RectAlign;
use crate::{
Atom, Atoms, Button, Event, EventFilter, Id, InputState, IntoAtoms, Key, LayerId, Popup,
PopupKind, ScrollArea, SurrenderFocusOn, TextEdit, Ui, WidgetText,
class::{ClassName, HasClasses as _},
text::{CCursor, CCursorRange, CharIndex},
vec2,
};
use super::{TextEditOutput, TextEditState};
#[derive(Clone)]
pub struct Suggestion {
pub insert: String,
pub content: Atoms<'static>,
}
impl Suggestion {
pub fn new(insert: impl Into<String>) -> Self {
let insert = insert.into();
Self {
content: insert.clone().into_atoms(),
insert,
}
}
#[inline]
pub fn content(mut self, content: impl IntoAtoms<'static>) -> Self {
self.content = content.into_atoms();
self
}
#[inline]
pub fn description(mut self, description: impl Into<WidgetText>) -> Self {
self.content.push_right(Atom::grow());
self.content.push_right(description.into().weak());
self
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CompletionQuery<'a> {
pub text: &'a str,
pub word: &'a str,
pub word_range: Range<CharIndex>,
}
impl CompletionQuery<'_> {
pub fn is_at_start(&self) -> bool {
self.word_range.start == CharIndex(0)
}
}
pub struct CompletionOutput {
pub text_edit: TextEditOutput,
pub accepted: Option<Suggestion>,
pub is_open: bool,
}
#[derive(Clone, Default)]
struct CompletionState {
selected: usize,
selected_word: String,
open: bool,
dismissed_word: Option<String>,
}
impl CompletionState {
fn load(ui: &Ui, id: Id) -> Self {
ui.data(|data| data.get_temp(id)).unwrap_or_default()
}
fn store(self, ui: &Ui, id: Id) {
ui.data_mut(|data| data.insert_temp(id, self));
}
}
#[derive(Default)]
struct PopupKeys {
down: usize,
up: usize,
accept: bool,
dismiss: bool,
}
impl PopupKeys {
fn moved_selection(&self) -> bool {
self.down != self.up
}
}
#[must_use = "You should call .show()"]
pub struct CompletionPopup<'a> {
id: Id,
is_word_boundary: Box<dyn Fn(char) -> bool + 'a>,
align: RectAlign,
max_height: f32,
accept_keys: Vec<Key>,
}
impl<'a> CompletionPopup<'a> {
pub const CLASS_SUGGESTION: ClassName = ClassName::from_static("egui::button::completion");
pub fn new(id: Id) -> Self {
Self {
id,
is_word_boundary: Box::new(char::is_whitespace),
align: RectAlign::TOP_START,
max_height: 200.0,
accept_keys: vec![Key::Enter, Key::Tab],
}
}
#[inline]
pub fn word_boundary(mut self, is_word_boundary: impl Fn(char) -> bool + 'a) -> Self {
self.is_word_boundary = Box::new(is_word_boundary);
self
}
#[inline]
pub fn align(mut self, align: RectAlign) -> Self {
self.align = align;
self
}
#[inline]
pub fn max_height(mut self, max_height: f32) -> Self {
self.max_height = max_height;
self
}
#[inline]
pub fn accept_keys(mut self, accept_keys: impl Into<Vec<Key>>) -> Self {
self.accept_keys = accept_keys.into();
self
}
pub fn show(
self,
ui: &mut Ui,
text_edit: TextEdit<'_>,
suggest: impl FnOnce(&CompletionQuery<'_>) -> Vec<Suggestion>,
) -> CompletionOutput {
let Self {
id,
is_word_boundary,
align,
max_height,
accept_keys,
} = self;
let mut state = CompletionState::load(ui, id);
let mut keys = PopupKeys::default();
if state.open {
ui.input_mut(|input| {
keys.down = consume_unmodified_key(input, Key::ArrowDown);
keys.up = consume_unmodified_key(input, Key::ArrowUp);
for &key in &accept_keys {
if 0 < consume_unmodified_key(input, key) {
keys.accept = true;
}
}
keys.dismiss = 0 < consume_unmodified_key(input, Key::Escape);
});
}
let event_filter = text_edit.get_event_filter();
let text_edit = text_edit.id(id).event_filter(EventFilter {
tab: event_filter.tab || state.open,
escape: event_filter.escape || state.open,
..event_filter
});
let popup_id = id.with("completion_popup");
let popup_layer = LayerId::new(PopupKind::Popup.order(), popup_id);
let pointer_on_popup = state.open
&& ui
.ctx()
.pointer_interact_pos()
.and_then(|pos| ui.ctx().layer_id_at(pos))
== Some(popup_layer);
let surrender_focus_on = ui
.ctx()
.options(|options| options.input_options.surrender_focus_on);
if pointer_on_popup {
ui.ctx().options_mut(|options| {
options.input_options.surrender_focus_on = SurrenderFocusOn::Never;
});
}
let (output, text) = text_edit.show_returning_text(ui);
ui.ctx()
.options_mut(|options| options.input_options.surrender_focus_on = surrender_focus_on);
let response = output.response.response.clone();
let cursor = output.cursor_range.map_or_else(
|| text.as_str().chars().count(),
|range| range.primary.index.0,
);
let word_range = word_range_before_cursor(text.as_str(), cursor, &*is_word_boundary);
let word = text.char_range(word_range.clone()).to_owned();
if keys.dismiss {
state.dismissed_word = Some(word.clone());
}
let mut suggestions = if state.dismissed_word.as_deref() == Some(word.as_str()) {
vec![]
} else {
suggest(&CompletionQuery {
text: text.as_str(),
word: &word,
word_range: word_range.clone(),
})
};
if state.selected_word != word {
state.selected = 0;
state.selected_word = word.clone();
}
if !suggestions.is_empty() {
let len = suggestions.len();
state.selected = (state.selected + keys.down + (len - keys.up % len)) % len;
}
let mut accepted_index = (keys.accept && !suggestions.is_empty()).then_some(state.selected);
let is_open = response.has_focus() && !suggestions.is_empty() && accepted_index.is_none();
let clicked = Popup::from_response(&response)
.id(popup_id)
.kind(PopupKind::Popup)
.open(is_open)
.align(align)
.align_alternatives(&[align.flipped_y()])
.width(response.rect.width())
.show(|ui| {
ScrollArea::vertical()
.max_height(max_height)
.show(ui, |ui| {
let pointer_moved = ui.input(|input| input.pointer.is_moving());
let mut clicked = None;
for (i, suggestion) in suggestions.iter().enumerate() {
let is_selected = i == state.selected;
let response = ui.add(
Button::selectable(is_selected, suggestion.content.clone())
.with_class(Self::CLASS_SUGGESTION)
.min_size(vec2(ui.available_width(), 0.0)),
);
if is_selected && keys.moved_selection() {
response.scroll_to_me(None);
}
if response.hovered() && pointer_moved {
state.selected = i;
}
if response.clicked() {
clicked = Some(i);
}
}
clicked
})
.inner
})
.and_then(|inner| inner.inner);
if clicked.is_some() {
accepted_index = clicked;
}
let accepted = accepted_index
.filter(|&i| i < suggestions.len())
.map(|i| suggestions.swap_remove(i));
if let Some(accepted) = &accepted {
text.delete_char_range(word_range.clone());
let mut ccursor = CCursor::new(word_range.start);
text.insert_text_at(&mut ccursor, &accepted.insert, usize::MAX);
let mut text_edit_state = TextEditState::load(ui.ctx(), id).unwrap_or_default();
text_edit_state
.cursor
.set_char_range(Some(CCursorRange::one(ccursor)));
text_edit_state.store(ui.ctx(), id);
state.dismissed_word = None;
state.selected = 0;
}
state.open = is_open;
state.store(ui, id);
CompletionOutput {
text_edit: output,
accepted,
is_open,
}
}
}
fn consume_unmodified_key(input: &mut InputState, key: Key) -> usize {
let mut count = 0;
input.events.retain(|event| {
let is_match = matches!(
event,
Event::Key {
key: event_key,
modifiers,
pressed: true,
..
} if *event_key == key && modifiers.is_none()
);
count += usize::from(is_match);
!is_match
});
count
}
fn word_range_before_cursor(
text: &str,
cursor: usize,
is_word_boundary: &dyn Fn(char) -> bool,
) -> Range<CharIndex> {
let start = text
.chars()
.take(cursor)
.enumerate()
.filter(|&(_, c)| is_word_boundary(c))
.last()
.map_or(0, |(i, _)| i + 1);
CharIndex(start)..CharIndex(cursor)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn word_range_before_cursor_works() {
let range = |text, cursor| {
let range = word_range_before_cursor(text, cursor, &char::is_whitespace);
range.start.0..range.end.0
};
assert_eq!(range("", 0), 0..0);
assert_eq!(range("/gr", 3), 0..3);
assert_eq!(range("hello /gr", 9), 6..9);
assert_eq!(range("hello /gr", 6), 6..6);
assert_eq!(range("hello /gr", 8), 6..8);
assert_eq!(range("héllo wörld", 11), 6..11);
let range = word_range_before_cursor("a.b", 3, &|c| c == '.');
assert_eq!(range.start.0..range.end.0, 2..3);
}
}