use egui::text::{CCursor, CCursorRange};
use egui::text_edit::TextEditState;
use egui::{Event, Id, Key, KeyboardShortcut, Modifiers, Ui};
use super::ChatLabels;
use super::popup_list::PopupList;
use crate::automation::{self, Details, Role};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Trigger {
pub symbol: char,
pub query: String,
}
pub struct ComposerResponse {
pub response: egui::Response,
pub submitted: Option<String>,
pub popup_pick: Option<usize>,
pub cancelled: bool,
pub trigger: Option<Trigger>,
}
#[derive(Clone, Copy, Default)]
struct State {
composing: bool,
cursor: usize,
dismissed_at: Option<usize>,
query_hash: u64,
ime_pass: u64,
had_focus: bool,
}
pub struct Composer<'a> {
id: &'a str,
draft: &'a mut String,
placeholder: &'a str,
suggestions: &'a [String],
enabled: bool,
rows: usize,
labels: Option<&'a ChatLabels>,
}
impl<'a> Composer<'a> {
pub fn new(id: &'a str, draft: &'a mut String) -> Self {
Composer { id, draft, placeholder: "", suggestions: &[], enabled: true, rows: 2, labels: None }
}
pub fn placeholder(mut self, text: &'a str) -> Self {
self.placeholder = text;
self
}
pub fn popup(mut self, suggestions: &'a [String]) -> Self {
self.suggestions = suggestions;
self
}
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
pub fn rows(mut self, rows: usize) -> Self {
self.rows = rows.clamp(1, 20);
self
}
pub fn labels(mut self, labels: &'a ChatLabels) -> Self {
self.labels = Some(labels);
self
}
pub fn show(mut self, ui: &mut Ui) -> ComposerResponse {
let installed = crate::labels::current(ui.ctx());
let labels: &ChatLabels = self.labels.unwrap_or(&installed.chat);
let edit_id = ui.make_persistent_id(("ashlar_ui::chat::composer", self.id));
let state_id = edit_id.with("state");
let mut st: State = ui.data(|d| d.get_temp(state_id)).unwrap_or_default();
let escape = ui.input(|i| i.events.iter().any(|e| matches!(e, Event::Key { key: Key::Escape, pressed: true, .. })));
if self.enabled && st.had_focus && escape && !ui.memory(|m| m.has_focus(edit_id)) {
ui.memory_mut(|m| m.request_focus(edit_id));
}
let focused = self.enabled && ui.memory(|m| m.has_focus(edit_id));
let pass = ui.ctx().cumulative_pass_nr();
ui.input(|i| {
for e in &i.events {
if let Event::Ime(ime) = e {
st.ime_pass = pass;
match ime {
egui::ImeEvent::Preedit { text, .. } => st.composing = !text.is_empty(),
egui::ImeEvent::Commit(_) => st.composing = false,
_ => {}
}
}
}
});
let caret = caret_byte(ui.ctx(), edit_id, self.draft);
let found = trigger_at(self.draft, caret);
let trigger = found.as_ref().map(|f| Trigger { symbol: f.symbol, query: f.query.clone() });
match &found {
None => st.dismissed_at = None,
Some(f) => {
let hash = egui::util::hash((f.start, &f.query));
if hash != st.query_hash {
st.query_hash = hash;
st.cursor = 0;
}
if st.dismissed_at.is_some_and(|d| d != f.start) {
st.dismissed_at = None;
}
}
}
let n = self.suggestions.len();
let popup_open = focused && n > 0 && found.as_ref().is_some_and(|f| st.dismissed_at != Some(f.start));
let mut submitted = None;
let mut popup_pick = None;
let mut cancelled = false;
let ime_recent = st.ime_pass != 0 && pass <= st.ime_pass.saturating_add(1);
if focused && !st.composing && !ime_recent {
if popup_open {
if consume(ui, Key::ArrowDown, |m| m.is_none()) {
st.cursor = (st.cursor + 1) % n;
}
if consume(ui, Key::ArrowUp, |m| m.is_none()) {
st.cursor = (st.cursor + n - 1) % n;
}
if consume(ui, Key::Enter, |m| m.is_none()) || consume(ui, Key::Tab, |m| m.is_none()) {
popup_pick = Some(st.cursor.min(n - 1));
}
if consume(ui, Key::Escape, |_| true) {
st.dismissed_at = found.as_ref().map(|f| f.start);
}
} else {
if consume(ui, Key::Escape, |_| true) {
cancelled = true;
}
if consume(ui, Key::Enter, |m| !m.shift && !m.alt) && !self.draft.trim().is_empty() {
submitted = Some(std::mem::take(self.draft));
}
}
}
if let (Some(i), Some(f)) = (popup_pick, &found) {
self.apply_pick(ui, edit_id, f, i);
st.dismissed_at = None;
}
let response = ui.add_enabled(
self.enabled,
egui::TextEdit::multiline(self.draft)
.id(edit_id)
.hint_text(self.placeholder)
.desired_rows(self.rows)
.desired_width(f32::INFINITY)
.return_key(KeyboardShortcut::new(Modifiers::SHIFT, Key::Enter))
.event_filter(egui::EventFilter { tab: popup_open, horizontal_arrows: true, vertical_arrows: true, escape: true }),
);
if submitted.is_some() {
set_caret(ui.ctx(), edit_id, 0);
}
let open_now = popup_open && popup_pick.is_none();
if open_now && let Some(f) = &found {
let popup_id = format!("{}.popup", self.id);
let mut cursor = st.cursor;
if let Some(i) = PopupList::new(&popup_id, self.suggestions, &mut cursor).label(&labels.suggestions).show_above(ui, response.rect) {
self.apply_pick(ui, edit_id, f, i);
popup_pick = Some(i);
ui.memory_mut(|m| m.request_focus(edit_id));
crate::wake::wake(ui.ctx(), crate::wake::Reason::INPUT);
}
st.cursor = cursor;
}
st.had_focus = response.has_focus();
ui.data_mut(|d| d.insert_temp(state_id, st));
automation::register_with(
ui,
&response,
self.id,
&labels.composer,
Role::Field,
Details { value: Some(self.draft.as_str()), selected: None },
);
ComposerResponse { response, submitted, popup_pick, cancelled, trigger }
}
fn apply_pick(&mut self, ui: &Ui, edit_id: Id, f: &Found, i: usize) {
let Some(s) = self.suggestions.get(i) else { return };
let Some(range) = self.draft.get(f.start..f.end).map(|_| f.start..f.end) else { return };
let insert = format!("{}{s} ", f.symbol);
self.draft.replace_range(range, &insert);
let caret_chars = self.draft.get(..f.start + insert.len()).map_or(0, |p| p.chars().count());
set_caret(ui.ctx(), edit_id, caret_chars);
}
}
fn consume(ui: &Ui, key: Key, mods: impl Fn(Modifiers) -> bool) -> bool {
ui.input_mut(|i| {
let pos = i.events.iter().position(|e| matches!(e, Event::Key { key: k, pressed: true, modifiers, .. } if *k == key && mods(*modifiers)));
match pos {
Some(p) => {
i.events.remove(p);
true
}
None => false,
}
})
}
fn caret_byte(ctx: &egui::Context, id: Id, text: &str) -> usize {
let chars = TextEditState::load(ctx, id).and_then(|s| s.cursor.char_range()).map(|r| r.primary.index);
match chars {
Some(c) => text.char_indices().nth(c.into()).map_or(text.len(), |(b, _)| b),
None => text.len(),
}
}
fn set_caret(ctx: &egui::Context, id: Id, chars: usize) {
let mut state = TextEditState::load(ctx, id).unwrap_or_default();
state.cursor.set_char_range(Some(CCursorRange::one(CCursor::new(chars))));
state.store(ctx, id);
}
struct Found {
symbol: char,
query: String,
start: usize,
end: usize,
}
fn trigger_at(text: &str, caret: usize) -> Option<Found> {
let before = text.get(..caret)?;
let start = before.rfind(char::is_whitespace).map_or(0, |i| i + before.get(i..).and_then(|s| s.chars().next()).map_or(1, char::len_utf8));
let word = before.get(start..)?;
let symbol = word.chars().next()?;
let line_start = start == 0 || before.get(..start).is_some_and(|p| p.ends_with('\n'));
let ok = match symbol {
'@' => true,
'/' => line_start,
_ => false,
};
ok.then(|| Found { symbol, query: word.get(symbol.len_utf8()..).unwrap_or("").to_owned(), start, end: caret })
}
#[cfg(test)]
pub(super) fn trigger_for_test(text: &str, caret: usize) -> Option<(char, String, usize)> {
trigger_at(text, caret).map(|f| (f.symbol, f.query, f.start))
}