use crate::ui::{Model, theme};
mod fire;
mod rows;
pub use fire::{fire, wanting};
pub use rows::rows;
pub const LEAD: char = '/';
pub const PLAN: &str = "plan";
pub const REFRESH: &str = "refresh";
pub const DELETE: &str = "delete";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Row {
pub word: &'static str,
pub summary: &'static str,
pub ops: &'static [&'static str],
}
impl Row {
const fn new(word: &'static str, summary: &'static str, ops: &'static [&'static str]) -> Row {
Row { word, summary, ops }
}
pub fn line(&self) -> String {
format!("/{} {}", self.word, self.summary)
}
}
pub fn parsed(draft: &str) -> Option<(&str, &str)> {
let line = draft.strip_prefix(LEAD)?;
let (word, rest) = line.split_once(char::is_whitespace).unwrap_or((line, ""));
Some((word.trim(), rest.trim()))
}
pub fn matching(model: &Model, draft: &str) -> Vec<Row> {
let Some((word, _)) = parsed(draft) else {
return Vec::new();
};
rows(model)
.into_iter()
.filter(|row| row.word.starts_with(word))
.collect()
}
const CHOSEN_ID: &str = "the menu's chosen row";
const WHY_ID: &str = "why the menu's word did not fire";
pub fn menu(ui: &mut egui::Ui, model: &mut Model) {
let draft = model.draft.clone();
let Some((_, rest)) = parsed(&draft) else {
return;
};
let rows = matching(model, &draft);
let id = egui::Id::new(CHOSEN_ID);
let mut at = ui.data(|d| d.get_temp::<usize>(id)).unwrap_or(0);
let (up, down, tab, enter, escape) = ui.input(|i| {
(
i.key_pressed(egui::Key::ArrowUp),
i.key_pressed(egui::Key::ArrowDown),
i.key_pressed(egui::Key::Tab),
i.key_pressed(egui::Key::Enter) && !i.modifiers.shift,
i.key_pressed(egui::Key::Escape),
)
});
if up {
at = at.saturating_sub(1);
}
if down {
at = at.saturating_add(1);
}
at = at.min(rows.len().saturating_sub(1));
ui.data_mut(|d| d.insert_temp(id, at));
let mut chose = None;
let line = theme::ROW + ui.spacing().item_spacing.y;
let why_id = egui::Id::new(WHY_ID);
let why = ui
.data(|d| d.get_temp::<(String, String)>(why_id))
.filter(|(said_of, _)| *said_of == draft)
.map(|(_, why)| why);
let small = ui.text_style_height(&egui::TextStyle::Small) + ui.spacing().item_spacing.y;
let hint = small + why.as_ref().map_or(0.0, |_| line);
let room = crate::ui::overlay::ceiling(ui) - 2.0 * theme::space::S - hint;
let (mut fit, mut tall) = (1, line);
while fit < rows.len() && tall + line <= room {
tall += line;
fit += 1;
}
let start = at
.saturating_sub(fit / 2)
.min(rows.len().saturating_sub(fit));
let shown = rows
.get(start..(start + fit).min(rows.len()))
.unwrap_or(&[]);
let height = tall + hint + 2.0 * theme::space::S;
crate::ui::overlay::float(ui, "the completion menu", height, |ui| {
ui.label(egui::RichText::new(HINT).small().color(theme::INK_WEAK));
if let Some(why) = &why {
ui.colored_label(theme::NOTICE, why);
}
for (i, row) in shown.iter().enumerate() {
let seat = theme::paint::row(ui, &row.line(), theme::INK, None, start + i == at, 0.0);
crate::ui::act::tag(&seat, row.ops);
if seat.clicked() {
chose = Some(*row);
}
}
if rows.is_empty() {
theme::paint::empty(ui, NOTHING);
}
});
if escape {
model.draft.clear();
return;
}
if tab && let Some(row) = rows.get(at) {
model.draft = format!("{LEAD}{} ", row.word);
return;
}
if enter {
chose = rows.get(at).copied();
}
if let Some(row) = chose {
if fire(model, row.word, rest) {
model.draft.clear();
} else if let Some(why) = wanting(model, row.word, rest) {
ui.data_mut(|d| d.insert_temp(why_id, (draft, why)));
ui.ctx().request_repaint();
}
}
}
pub const NOTHING: &str = "nothing here answers to that — Esc clears it";
pub const HINT: &str = "arrows move · Tab completes · Enter fires · Esc clears";
#[cfg(test)]
mod tests;