use bevy_app::{App, Update};
use bevy_ecs::change_detection::{DetectChanges, DetectChangesMut};
use bevy_ecs::hierarchy::Children;
use bevy_ecs::prelude::{ChildOf, Commands, Entity, Local, Mut, On, Query, Res, ResMut, With};
use bevy_ecs::schedule::IntoScheduleConfigs;
use bevy_ecs::system::SystemParam;
use bevy_input::keyboard::{Key, KeyboardInput};
use bevy_input_focus::{FocusCause, FocusedInput, InputFocus};
use plurimus::core::UiWidget;
use plurimus::term::bevy_compat::HeldModifiers;
use plurimus::ui::{KeyBinding, ModalDismiss, first_bound};
use plurimus::widgets::ratatui_widgets::paragraph::{Paragraph, Wrap};
use plurimus::widgets::{Activate, Submit, TextInput, ValueChange};
use toml::Value;
use super::LocatedIssue;
use super::build::{EditForm, FieldLabel, FormButton, FormField, HelpFoot};
use super::cells::parse_field;
use super::codec::{is_within, parse_text, set_path};
use super::domain::FieldKind;
use super::draft::{Draft, DraftEditor};
use super::editing::{self, EditSession, SessionFocus};
use super::field::{self, Fields, FormTree};
use super::group;
use super::select::{PickChanged, Select};
use super::table::Row;
use super::trigger;
use crate::commands::tui::pane::Framed;
use crate::commands::tui::theme::{Repainted, Theme};
pub fn plugin(app: &mut App) {
app.init_resource::<EditSession>();
app.add_systems(
Update,
(editing::sync_item_form, seed_fields)
.chain()
.in_set(super::EditSystems::Seed),
);
app.add_systems(
Update,
(mark_issues, show_help, field::light_brackets).in_set(Repainted),
);
app.add_systems(Update, field::place_brackets.after(trigger::place_slots));
app.add_systems(
Update,
(
group::place_dependents,
group::place_stops
.after(group::place_dependents)
.after(trigger::place_slots),
group::offer_unused.before(super::select::sync_selects),
)
.in_set(super::EditSystems::Place),
);
app.add_observer(handle_text_change);
app.add_observer(handle_text_submit);
app.add_observer(handle_check_change);
app.add_observer(handle_pick_change);
app.add_observer(handle_slider_change);
app.add_observer(handle_modal_dismiss);
}
fn handle_modal_dismiss(
dismiss: On<ModalDismiss>,
forms: Query<(), With<EditForm>>,
mut state: SessionFocus,
) {
if forms.contains(dismiss.entity) {
state.leave();
}
}
#[derive(SystemParam)]
pub struct FormTargets<'w, 's> {
parents: Query<'w, 's, &'static ChildOf>,
forms: Query<'w, 's, &'static EditForm>,
}
impl FormTargets<'_, '_> {
fn owner(&self, target: Entity) -> Option<(Entity, &EditForm)> {
std::iter::once(target)
.chain(self.parents.iter_ancestors(target))
.find_map(|entity| Some((entity, self.forms.get(entity).ok()?)))
}
pub fn form_of(&self, target: Entity) -> Option<Entity> {
self.owner(target).map(|(form, _)| form)
}
fn field<'a>(&self, target: Entity, fields: &'a Query<&FormField>) -> Option<&'a FormField> {
std::iter::once(target)
.chain(self.parents.iter_ancestors(target))
.find_map(|entity| fields.get(entity).ok())
}
}
#[derive(SystemParam)]
struct FormIssues<'w, 's> {
draft: Res<'w, Draft>,
session: Res<'w, EditSession>,
targets: FormTargets<'w, 's>,
}
impl FormIssues<'_, '_> {
fn shown(&self) -> Option<(Entity, Option<Row>)> {
let editing = self.session.0.as_ref()?;
Some((editing.form?, editing.slot.row()))
}
fn of<'a>(&self, located: &[LocatedIssue<'a>], form: Entity, key: &str) -> Option<&'a str> {
let ops = self.targets.forms.get(form).ok()?.ops;
let index = match (ops.list, self.shown()) {
(None, _) => None,
(Some(_), Some((shown, Some(Row(index))))) if shown == form => Some(index),
(Some(_), _) => return None,
};
super::field_issue(located, ops, index, key)
}
}
const ISSUE_MARK: &str = " !";
fn mark_issues(
issues: FormIssues,
theme: Res<Theme>,
mut labels: Query<(Entity, Mut<FieldLabel>, &mut UiWidget)>,
mut last_shown: Local<Option<(Entity, Option<Row>)>>,
) {
let shown = issues.shown();
let is_stale = issues.draft.is_changed()
|| theme.is_changed()
|| *last_shown != shown
|| labels.iter().any(|(_, label, _)| label.is_added());
if !is_stale {
return;
}
*last_shown = shown;
let located = super::located_issues(&issues.draft);
for (entity, mut label, mut widget) in &mut labels {
let form = issues.targets.owner(entity).map(|(form, _)| form);
let is_marked = form.is_some_and(|form| issues.of(&located, form, label.key).is_some());
if is_marked == label.is_marked && !theme.is_changed() {
continue;
}
label.is_marked = is_marked;
*widget = UiWidget::new(if is_marked {
Paragraph::new(format!("{}{ISSUE_MARK}", label.label)).style(theme.exceeded())
} else {
Paragraph::new(label.label)
});
}
}
fn show_help(
issues: FormIssues,
focus: Res<InputFocus>,
theme: Res<Theme>,
fields: Query<&FormField>,
mut feet: Query<(&ChildOf, &mut UiWidget), With<HelpFoot>>,
) {
if !issues.draft.is_changed() && !focus.is_changed() && !theme.is_changed() {
return;
}
let targets = &issues.targets;
let focused = focus.get();
let form = focused.and_then(|widget| targets.owner(widget));
let field = focused.and_then(|widget| targets.field(widget, &fields));
let located = super::located_issues(&issues.draft);
for (parent, mut widget) in &mut feet {
let here = field.filter(|_| form.is_some_and(|(form, _)| form == parent.parent()));
let issue = here.and_then(|field| issues.of(&located, parent.parent(), field.spec.key));
let (said, style) = match (issue, here) {
(Some(issue), _) => (issue, theme.exceeded()),
(None, Some(field)) => (field.spec.help, theme.dimmed()),
(None, None) => ("", theme.dimmed()),
};
let text = Paragraph::new(said.to_owned())
.style(style)
.wrap(Wrap { trim: true });
*widget = UiWidget::new(text);
}
}
#[derive(SystemParam)]
pub struct Items<'w, 's> {
fields: Query<'w, 's, (&'static FormField, &'static ChildOf)>,
texts: Query<'w, 's, (), With<TextInput>>,
parts: Query<
'w,
's,
(
Entity,
&'static FormField,
Option<&'static TextInput>,
Option<&'static Select>,
),
>,
targets: FormTargets<'w, 's>,
rows: Query<'w, 's, &'static Children>,
slots: trigger::Slots<'w, 's>,
}
impl Items<'_, '_> {
fn set(&self, widget: Entity, value: Option<Value>, state: &mut SessionFocus) {
let (Ok((field, row)), Some(editing)) = (self.fields.get(widget), state.session.0.as_mut())
else {
return;
};
let key = field.spec.key;
let (value, complaint) = match field.spec.kind {
FieldKind::Trigger => {
let group = self
.rows
.get(row.parent())
.map_or(&[][..], |group| &group[..]);
trigger::held(group, &self.slots)
}
FieldKind::Listed(_) => group::listed(self.parts(widget, key)),
FieldKind::Order(..) => group::ordered(self.parts(widget, key)),
FieldKind::Presence(ticked) => {
editing.is_seeded = false;
editing.incomplete.retain(|held, _| !is_within(held, key));
let is_ticked = value.as_ref().and_then(Value::as_bool) == Some(true);
let table = is_ticked.then(|| Value::Table(ticked.parse().unwrap_or_default()));
(table, None)
}
_ => (value, None),
};
match complaint {
Some(complaint) => editing.incomplete.insert(key, complaint),
None => editing.incomplete.remove(key),
};
set_path(&mut editing.snapshot, key, value);
}
fn parts(&self, widget: Entity, key: &str) -> impl Iterator<Item = (usize, Option<Value>)> {
let form = self.targets.form_of(widget);
self.parts
.iter()
.filter_map(move |(entity, field, text, select)| {
let (FieldKind::Listed(place) | FieldKind::Order(_, place)) = field.spec.kind
else {
return None;
};
let is_part = field.spec.key == key && self.targets.form_of(entity) == form;
let value = match (text, select) {
(Some(text), _) => parse_field(field.spec.kind, text.value()),
(_, select) => select.and_then(Select::value),
};
is_part.then_some((place, value))
})
}
fn read(&self, widget: Entity, text: &str) -> Option<Value> {
let kind = self.fields.get(widget).map(|(field, ..)| field.spec.kind);
kind.map_or_else(|_| parse_text(text), |kind| parse_field(kind, text))
}
fn key_of(&self, widget: Entity) -> Option<&'static str> {
self.fields
.get(widget)
.ok()
.map(|(field, ..)| field.spec.key)
}
}
fn seed_fields(
mut session: ResMut<EditSession>,
draft: Res<Draft>,
mut fields: Fields,
mut frames: Query<&mut Framed>,
mut focus: ResMut<InputFocus>,
) {
let is_edited = session.is_changed();
let Some(editing) = session.bypass_change_detection().0.as_mut() else {
return;
};
let Some(form) = editing.form else {
return;
};
if editing.is_seeded && is_edited {
fields.show_rates(form, editing, focus.get());
}
if editing.is_seeded && focus.is_changed() {
fields.show_texts(form, editing, focus.get());
}
if !editing.is_seeded {
editing.is_seeded = true;
fields.show(form, editing, &draft.plan, focus.get());
if let Ok(mut framed) = frames.get_mut(form) {
Framed::retitle(&mut framed, &editing.title());
}
}
if std::mem::take(&mut editing.takes_keyboard) {
let held = if editing.is_alone {
Some(form)
} else {
fields.tree.first(form)
};
if let Some(held) = held {
focus.set(held, FocusCause::Navigated);
}
}
}
fn handle_text_change(change: On<ValueChange<String>>, items: Items, mut state: SessionFocus) {
items.set(
change.source,
items.read(change.source, &change.value),
&mut state,
);
}
fn handle_text_submit(
submit: On<Submit>,
items: Items,
mut state: SessionFocus,
mut editor: DraftEditor,
) {
items.set(
submit.entity,
items.read(submit.entity, &submit.value),
&mut state,
);
state.apply(items.key_of(submit.entity), &mut editor);
}
fn handle_check_change(change: On<ValueChange<bool>>, items: Items, mut state: SessionFocus) {
items.set(
change.source,
Some(Value::Boolean(change.value)),
&mut state,
);
}
fn handle_pick_change(change: On<PickChanged>, items: Items, mut state: SessionFocus) {
items.set(change.entity, change.value.clone(), &mut state);
}
fn handle_slider_change(change: On<ValueChange<f32>>, items: Items, mut state: SessionFocus) {
let value = super::field::slider_value(change.value);
items.set(change.source, Some(value), &mut state);
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum FormKey {
Enter,
Leave,
Into,
}
const FORM_KEYS: &[(KeyBinding, FormKey)] = &[
(KeyBinding::new(Key::Enter), FormKey::Enter),
(KeyBinding::new(Key::Escape), FormKey::Leave),
(KeyBinding::new(Key::Tab), FormKey::Into),
];
pub fn handle_form_key(
mut input: On<FocusedInput<KeyboardInput>>,
(targets, items, tree, mut commands): (FormTargets, Items, FormTree, Commands),
held: HeldModifiers,
mut state: SessionFocus,
mut editor: DraftEditor,
) {
let target = input.original_event_target();
let Some(form) = targets.form_of(target) else {
return;
};
let Some(key) = first_bound(FORM_KEYS, &input.input, held.get()) else {
return;
};
if key == FormKey::Into && form != target {
return;
}
input.propagate(false);
let is_alone = state.session.0.as_ref().is_some_and(|held| held.is_alone);
match key {
FormKey::Enter | FormKey::Into if form == target => {
if let Some(first) = tree.first(form) {
state.enter(first);
}
}
FormKey::Enter if !items.texts.contains(target) => {
act(
FormButton::Apply,
items.key_of(target),
&mut state,
&mut editor,
&mut commands,
);
}
FormKey::Enter | FormKey::Into => {}
FormKey::Leave if is_alone => state.focus(form),
FormKey::Leave => state.leave(),
}
}
pub fn handle_button(
activate: On<Activate>,
buttons: Query<&FormButton>,
mut state: SessionFocus,
mut editor: DraftEditor,
mut commands: Commands,
) {
if let Ok(&which) = buttons.get(activate.entity) {
act(which, None, &mut state, &mut editor, &mut commands);
}
}
fn act(
which: FormButton,
label: Option<&str>,
state: &mut SessionFocus,
editor: &mut DraftEditor,
commands: &mut Commands,
) {
let after = state.session.0.as_ref().and_then(|held| held.ops.after);
let is_done = match which {
FormButton::Apply => state.apply(label, editor),
FormButton::Discard => {
state.discard();
true
}
};
if is_done && let Some(after) = after {
after(which, commands);
}
}