use bevy_ecs::prelude::*;
use bevy_ecs::system::SystemParam;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::style::Stylize;
use ratatui::text::{Line, Span};
use rig_ecs::stage::{Effect, Outcome, Settled, SupersededBy};
use rig_harness::ask::Ask;
use rig_harness::pick::{Chosen, Highlighted, PickPreview, Picker};
use rig_tools::shorten;
use unicode_width::UnicodeWidthStr;
use ratatui::layout::Constraint;
use super::editor::{Editor, MainEditor};
use super::pane::{TranscriptPane, TranscriptSource};
use super::panel::{PanelCanvas, Placement, TuiPanel};
use super::view::TuiView;
use super::view::{ItemRow, ScrollTo, TranscriptItem};
const PAGE: usize = 10;
#[derive(Component, Clone, Debug, Default, PartialEq, Eq)]
pub struct ChoiceKeys(pub Vec<(KeyCode, ChoiceAction)>);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ChoiceAction {
Tick,
TickAll,
TickNone,
More,
Answer,
Close,
}
#[derive(Component, Clone, Debug, Default, PartialEq, Eq)]
pub struct PickerInput {
pub(crate) filter: String,
pub(crate) selected: usize,
pub(crate) ticked: Vec<usize>,
pub(crate) scroll: usize,
pub(crate) draft: Option<String>,
pub(crate) more: bool,
pub(crate) touched: bool,
pub(crate) picked: Option<(String, String)>,
}
impl PickerInput {
pub fn filter(&self) -> &str {
&self.filter
}
pub fn ticked(&self) -> &[usize] {
&self.ticked
}
pub fn in_input(&self) -> bool {
self.draft.is_some()
}
}
pub(crate) struct Listing<'a> {
pub(crate) title: &'a str,
pub(crate) body: &'a str,
pub(crate) items: Vec<Item<'a>>,
pub(crate) free_text: bool,
pub(crate) ticks: bool,
pub(crate) multiple: bool,
pub(crate) selected: usize,
pub(crate) edit: Option<&'a str>,
pub(crate) keys: &'a [(KeyCode, ChoiceAction)],
}
pub(crate) struct Item<'a> {
pub(crate) id: &'a str,
pub(crate) label: &'a str,
pub(crate) detail: Option<&'a str>,
pub(crate) checked: bool,
pub(crate) more: Option<&'a str>,
}
impl Item<'_> {
pub(crate) fn line(&self, width: usize) -> Line<'static> {
let Some(detail) = self.detail else {
return Line::from(shorten(self.label, width));
};
let reserve = match detail.width() {
short if short <= width / 2 => short,
_ => width / 3,
};
let label = shorten(self.label, width.saturating_sub(reserve + 2));
let detail = shorten(detail, width.saturating_sub(label.width() + 2));
let gap = width.saturating_sub(label.width() + detail.width());
Line::from(vec![
Span::from(label),
Span::from(" ".repeat(gap)),
Span::from(detail).dim(),
])
}
}
impl Listing<'_> {
pub(crate) fn more(&self, choosing: &PickerInput) -> Option<&str> {
let rows = self.rows(&choosing.filter);
match rows.get(choosing.selected) {
Some(Row::Item(index)) if choosing.more => self.items.get(*index)?.more,
_ => None,
}
}
pub(crate) fn rows(&self, filter: &str) -> Vec<Row> {
let lower = filter.to_lowercase();
let mut rows: Vec<Row> = (self.items.iter().enumerate())
.filter(|(_, item)| {
let label = item.label.to_lowercase();
lower.split_whitespace().all(|word| label.contains(word))
})
.map(|(index, _)| Row::Item(index))
.collect();
if self.free_text {
rows.push(Row::Typed);
}
rows
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Row {
Item(usize),
Typed,
}
#[derive(SystemParam)]
pub(crate) struct Choices<'w, 's> {
pickers: Query<'w, 's, &'static Picker>,
asks: Query<'w, 's, &'static Ask, (Without<Outcome>, Without<Settled>, Without<SupersededBy>)>,
keys: Query<'w, 's, &'static ChoiceKeys>,
}
impl Choices<'_, '_> {
pub(crate) fn shown(&self, entity: Entity) -> Option<Listing<'_>> {
let keys = self.keys.get(entity).map_or(&[][..], |keys| &keys.0);
if let Ok(picker) = self.pickers.get(entity) {
return Some(Listing {
title: &picker.title,
body: &picker.body,
items: picker
.items
.iter()
.map(|item| Item {
id: &item.command,
label: &item.label,
detail: item.detail.as_deref(),
checked: item.checked,
more: None,
})
.collect(),
free_text: picker.free_text,
ticks: picker.multiple,
multiple: picker.multiple,
selected: picker.selected,
edit: None,
keys,
});
}
Some(listing(self.asks.get(entity).ok()?, keys))
}
}
fn listing<'a>(ask: &'a Ask, keys: &'a [(KeyCode, ChoiceAction)]) -> Listing<'a> {
Listing {
title: &ask.title,
body: &ask.body,
items: ask
.choices
.iter()
.map(|choice| Item {
id: &choice.id,
label: &choice.label,
detail: None,
checked: choice.checked,
more: choice.more.as_deref(),
})
.collect(),
free_text: ask.free_text,
ticks: ask.multiple || (ask.free_text && !ask.choices.is_empty()),
multiple: ask.multiple,
selected: 0,
edit: ask
.initial
.as_deref()
.or((ask.multiline || ask.inline).then_some("")),
keys,
}
}
fn carried(choosing: PickerInput, old: &Ask, new: &Ask) -> PickerInput {
let id_at = |index: &usize| old.choices.get(*index).map(|choice| choice.id.as_str());
let ticked: Vec<&str> = choosing.ticked.iter().filter_map(id_at).collect();
let was = |id: &str| old.choices.iter().any(|choice| choice.id == id);
let choices = new.choices.iter().enumerate();
let ticked = choices.filter(|(_, choice)| match was(&choice.id) {
true => ticked.contains(&choice.id.as_str()),
false => new.multiple && choice.checked,
});
let (old_rows, new_rows) = (
listing(old, &[]).rows(&choosing.filter),
listing(new, &[]).rows(&choosing.filter),
);
let row = match old_rows.get(choosing.selected) {
Some(Row::Item(index)) => id_at(index)
.and_then(|id| new.choices.iter().position(|choice| choice.id == id))
.map(Row::Item),
other => other.copied(),
};
let selected = row.and_then(|row| new_rows.iter().position(|known| *known == row));
PickerInput {
ticked: ticked.map(|(index, _)| index).collect(),
selected: selected.unwrap_or_default(),
..choosing
}
}
pub(crate) type Showing<'w, 's> = Query<'w, 's, (Entity, &'static mut PickerInput)>;
pub(crate) fn queue_choices(
mut view: ResMut<TuiView>,
mut input: Query<&mut Editor, With<MainEditor>>,
(pickers, filled): (Query<Entity, Added<Picker>>, Query<(), Changed<Picker>>),
asks: Query<(Entity, &Effect, &Ask), Added<Ask>>,
(superseded, every_ask): (Query<(&Ask, &SupersededBy)>, Query<&Ask>),
mut showing: Showing,
choices: Choices,
mut commands: Commands,
) {
let state = view.bypass_change_detection();
let successor = |entity: &Entity| superseded.get(*entity).ok().map(|(_, by)| by.0);
for slot in state.queue.iter_mut().chain(state.inline.iter_mut()) {
*slot = successor(slot).unwrap_or(*slot);
}
let mut seen = Vec::new();
state.queue.retain(|entity| {
let first = !seen.contains(entity);
seen.push(*entity);
first
});
let mut before = None;
for (entity, shown) in &showing {
before = Some(entity);
let old = superseded.get(entity).ok();
let new = old.and_then(|(_, by)| Some((by.0, every_ask.get(by.0).ok()?)));
if let (Some((old, _)), Some((next, new))) = (old, new)
&& choices.shown(next).is_some()
{
commands
.entity(entity)
.try_remove::<(PickerInput, Highlighted)>();
commands
.entity(next)
.try_insert(carried(shown.clone(), old, new));
before = Some(next);
}
}
let inline_before = state.inline.clone();
let mut asked: Vec<(Entity, &Effect, &Ask)> = asks.iter().collect();
asked.sort_by_key(|(_, effect, _)| effect.id());
let open = |entity: &Entity| choices.shown(*entity).is_some();
state.queue.retain(open);
state.inline.retain(open);
let (inline, asked): (Vec<_>, Vec<_>) = asked.into_iter().partition(|(.., ask)| ask.inline);
for (entity, ..) in inline {
if !state.inline.contains(&entity) && open(&entity) {
state.inline.push(entity);
}
}
for entity in pickers.iter().chain(asked.into_iter().map(|(ask, ..)| ask)) {
if !state.queue.contains(&entity) && open(&entity) {
state.queue.push(entity);
}
}
if state.inline != inline_before {
view.set_changed();
}
let front = view.queue.first().copied();
if front == before {
let refilled = front.filter(|front| filled.contains(*front));
let shown = refilled.and_then(|front| Some((showing.get_mut(front).ok()?, front)));
if let Some(((_, mut state), front)) = shown
&& let Some(listing) = choices.shown(front)
{
refill(&mut state, &listing);
}
return;
}
let mut main = input.single_mut().ok();
let mut none = Editor::default();
let input = main.as_deref_mut().unwrap_or(&mut none);
show_first(&view, &choices, (&showing, input), &mut commands);
}
fn refill(state: &mut PickerInput, shown: &Listing<'_>) {
let rows = shown.rows(&state.filter);
let item = |row: &Row| match row {
Row::Item(index) => shown.items.get(*index),
Row::Typed => None,
};
let picked = state.picked.as_ref();
let by = |same: &dyn Fn(&Item<'_>, &(String, String)) -> bool| {
picked.and_then(|picked| {
rows.iter()
.position(|row| item(row).is_some_and(|item| same(item, picked)))
})
};
let found = by(&|item, (id, _)| !id.is_empty() && item.id == id)
.or_else(|| by(&|item, (_, label)| item.label == label));
let selected = found.unwrap_or(state.selected);
state.selected = selected.min(rows.len().saturating_sub(1));
state.ticked.retain(|index| *index < shown.items.len());
}
pub(crate) fn show_first(
view: &TuiView,
choices: &Choices,
(showing, input): (&Showing, &mut Editor),
commands: &mut Commands,
) {
for (entity, shown) in showing {
if let Some(draft) = &shown.draft {
input.set(draft.clone());
}
commands
.entity(entity)
.try_remove::<(PickerInput, Highlighted)>();
}
let first = view.queue.iter().find_map(|&entity| {
let shown = choices.shown(entity)?;
let draft = shown.edit.map(|initial| {
let draft = input.take();
input.set(initial);
draft
});
let items = shown.items.iter().enumerate();
let ticked: Vec<usize> = items
.filter(|(_, item)| shown.multiple && item.checked)
.map(|(index, _)| index)
.collect();
let state = PickerInput {
filter: String::new(),
selected: shown.selected.min(shown.items.len().saturating_sub(1)),
touched: !ticked.is_empty(),
ticked,
scroll: 0,
draft,
more: false,
picked: None,
};
Some((entity, state))
});
if let Some((entity, state)) = first {
commands.entity(entity).try_insert(state);
}
}
pub(crate) fn mark_highlighted(
mut showing: Query<(Entity, &mut PickerInput, Option<&Highlighted>)>,
filled: Query<(), Changed<Picker>>,
choices: Choices,
mut commands: Commands,
) {
for (entity, mut state, highlighted) in &mut showing {
let Some(shown) = choices.shown(entity).filter(|_| state.draft.is_none()) else {
continue;
};
let rows = shown.rows(&state.filter);
let index = match rows.get(state.selected) {
Some(Row::Item(index)) => Some(*index),
_ => None,
};
let item = index.and_then(|index| shown.items.get(index));
let picked = item.map(|item| (item.id.to_owned(), item.label.to_owned()));
if state.picked != picked && !filled.contains(entity) {
state.bypass_change_detection().picked = picked;
}
match index {
Some(index) if highlighted != Some(&Highlighted(index)) => {
commands.entity(entity).try_insert(Highlighted(index));
}
None if highlighted.is_some() => {
commands.entity(entity).try_remove::<Highlighted>();
}
_ => {}
}
}
}
pub(crate) fn choice_key(
key: KeyEvent,
choosing: &mut PickerInput,
shown: &Listing<'_>,
) -> Option<Option<Chosen>> {
let control = key.modifiers.contains(KeyModifiers::CONTROL);
let alt = key.modifiers.contains(KeyModifiers::ALT);
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
let rows = shown.rows(&choosing.filter);
let last = rows.len().saturating_sub(1);
let typing =
!choosing.filter.is_empty() || matches!(rows.get(choosing.selected), Some(Row::Typed));
let bound = shown.keys.iter().find(|(code, _)| {
*code == key.code && !control && !alt && !(typing && matches!(code, KeyCode::Char(_)))
});
if let Some((_, action)) = bound {
return choice_action(*action, choosing, shown, &rows);
}
match key.code {
KeyCode::Up if shift => choosing.scroll = choosing.scroll.saturating_sub(1),
KeyCode::Down if shift => choosing.scroll = choosing.scroll.saturating_add(1),
KeyCode::Esc => return Some(None),
KeyCode::Char('c') if control => return Some(None),
KeyCode::Char(_) if control || key.modifiers.contains(KeyModifiers::ALT) => {}
KeyCode::Char(character) => {
choosing.filter.push(character);
choosing.selected = 0;
}
KeyCode::Backspace => {
choosing.filter.pop();
choosing.selected = 0;
}
KeyCode::Up => choosing.selected = choosing.selected.saturating_sub(1),
KeyCode::Down => choosing.selected = (choosing.selected + 1).min(last),
KeyCode::PageUp => choosing.selected = choosing.selected.saturating_sub(PAGE),
KeyCode::PageDown => choosing.selected = (choosing.selected + PAGE).min(last),
KeyCode::Right => choosing.more = true,
KeyCode::Left => choosing.more = false,
KeyCode::Tab if shown.ticks => {
if tick(choosing, shown, &rows) {
choosing.selected = (choosing.selected + 1).min(last);
}
}
KeyCode::Enter => return answer(choosing, shown, &rows),
_ => {}
}
None
}
fn choice_action(
action: ChoiceAction,
choosing: &mut PickerInput,
shown: &Listing<'_>,
rows: &[Row],
) -> Option<Option<Chosen>> {
match action {
ChoiceAction::Tick if shown.ticks => {
tick(choosing, shown, rows);
}
ChoiceAction::TickAll if shown.multiple => {
let items = rows.iter().filter_map(|row| match row {
Row::Item(index) => Some(*index),
Row::Typed => None,
});
choosing.ticked = items.collect();
choosing.ticked.sort_unstable();
choosing.touched = true;
}
ChoiceAction::TickNone if shown.ticks => {
choosing.ticked.clear();
choosing.touched = true;
}
ChoiceAction::More => choosing.more = !choosing.more,
ChoiceAction::Answer => return answer(choosing, shown, rows),
ChoiceAction::Close => return Some(None),
ChoiceAction::Tick | ChoiceAction::TickAll | ChoiceAction::TickNone => {}
}
None
}
fn tick(choosing: &mut PickerInput, shown: &Listing<'_>, rows: &[Row]) -> bool {
let Some(Row::Item(index)) = rows.get(choosing.selected) else {
return false;
};
match choosing.ticked.binary_search(index) {
Ok(at) => {
choosing.ticked.remove(at);
}
Err(_) if !shown.multiple => choosing.ticked = vec![*index],
Err(at) => choosing.ticked.insert(at, *index),
}
choosing.touched = true;
true
}
fn answer(choosing: &PickerInput, shown: &Listing<'_>, rows: &[Row]) -> Option<Option<Chosen>> {
match rows.get(choosing.selected)? {
Row::Item(_) if choosing.touched && shown.multiple => {
Some(Some(Chosen::items(choosing.ticked.clone())))
}
Row::Item(index) => Some(Some(Chosen::item(*index))),
Row::Typed if choosing.filter.trim().is_empty() => None,
Row::Typed => {
let chosen = Chosen::items(choosing.ticked.clone());
Some(Some(chosen.with_text(choosing.filter.trim())))
}
}
}
#[derive(Component)]
pub(crate) struct PreviewPane;
pub(crate) fn follow_previews(
showing: Query<(Entity, &PickerInput, Ref<PickPreview>)>,
panes: Query<(Entity, &ChildOf), With<PreviewPane>>,
mut commands: Commands,
) {
let previewing = |picker: Entity| {
let shown = showing.get(picker).ok();
let shown = shown.filter(|(_, state, _)| !state.in_input());
shown.filter(|(.., preview)| matches!(**preview, PickPreview::Conversation { .. }))
};
for (pane, of) in &panes {
if previewing(of.parent()).is_none() {
commands.entity(pane).despawn();
}
}
for (picker, _, preview) in &showing {
let PickPreview::Conversation { conversation, at } = &*preview else {
continue;
};
let pane = panes.iter().find(|(_, of)| of.parent() == picker);
if previewing(picker).is_none() || pane.is_some() && !preview.is_changed() {
continue;
}
let source = TranscriptSource::Value(conversation.clone());
let shown = TranscriptPane::new(source).titled("preview");
let pane = match pane {
Some((pane, _)) => commands.entity(pane).insert(shown).id(),
None => {
let within = Placement::Within {
panel: picker,
part: 1,
of: 2,
};
let spawned = (shown, TuiPanel::new(within), PreviewPane, ChildOf(picker));
commands.spawn(spawned).id()
}
};
let item = at.map(TranscriptItem::Message);
commands.trigger(ScrollTo {
pane,
item,
row: ItemRow::Top(0),
});
}
}
#[derive(Component, Clone, Copy, Debug, Default)]
#[require(TuiPanel = TuiPanel::new(Placement::Bottom(Constraint::Length(1))))]
pub struct InlineStrip;
pub(crate) fn draw_inline_strips(
mut strips: Query<&mut PanelCanvas, With<InlineStrip>>,
view: Res<TuiView>,
choices: Choices,
) {
let Some(ask) = view.inline.first().and_then(|&ask| choices.shown(ask)) else {
return;
};
for mut canvas in &mut strips {
canvas.render(inline_line(&ask));
}
}
fn inline_line(ask: &Listing<'_>) -> Line<'static> {
let mut spans = vec![
Span::from("? ").yellow().bold(),
Span::from(ask.title.to_owned()).bold(),
Span::from(" "),
];
let typed = ask.free_text.then_some("Answer…");
let labels = ask.items.iter().map(|item| item.label).chain(typed);
for (index, label) in labels.take(9).enumerate() {
spans.push(Span::from(format!(" [Alt+{} {label}]", index + 1)).cyan());
}
spans.push(Span::from(" [Alt+0 Dismiss]").dim());
Line::from(spans)
}
#[cfg(test)]
mod tests;