use std::collections::BTreeMap;
use bevy_ecs::change_detection::{DetectChanges, DetectChangesMut, Mut};
use bevy_ecs::prelude::{Commands, Component, Entity, In, Query, Res, ResMut, Resource, World};
use bevy_ecs::system::SystemParam;
use bevy_input_focus::{FocusCause, InputFocus};
use plurimus::ui::ModalOpen;
use toml::Table;
use super::applies;
use super::build;
use super::domain::{ListOps, Ops, Target};
use super::draft::{Draft, DraftEditor};
use super::table::{DomainTable, Row};
use crate::commands::tui::command::Outcome;
use crate::commands::tui::confirm::{Answer, Confirm};
use crate::commands::tui::hints::Hints;
use crate::commands::tui::journal;
use crate::commands::tui::nav::Page;
use crate::commands::tui::overlay::{self, Standing};
use crate::commands::tui::pane::Framed;
use crate::commands::tui::scope::KeyScope;
use crate::commands::tui::session::Session;
#[derive(Clone, Copy)]
pub enum Slot {
At(Row),
New(ListOps),
}
impl Slot {
pub(super) const fn row(self) -> Option<Row> {
match self {
Self::At(row) => Some(row),
Self::New(_) => None,
}
}
}
#[derive(Resource, Default)]
pub struct EditSession(pub(super) Option<Editing>);
pub(super) struct Editing {
pub(super) ops: Ops,
pub(super) table: Option<Entity>,
pub(super) slot: Slot,
pub(super) form: Option<Entity>,
pub(super) snapshot: Table,
pristine: Table,
pub(super) stale: Vec<&'static str>,
pub(super) incomplete: BTreeMap<&'static str, &'static str>,
pub(super) is_seeded: bool,
pub(super) takes_keyboard: bool,
pub(super) is_alone: bool,
}
impl Editing {
fn complaint(&self) -> Option<String> {
let (key, complaint) = self.held_back()?;
let spec = super::cells::field_of(self.ops.fields, key);
let named = spec.map_or(key, |spec| spec.label);
Some(format!("{named}: {complaint}"))
}
pub(super) fn new(ops: Ops, table: Option<Entity>, slot: Slot, draft: &Draft) -> Self {
let pristine = match slot {
Slot::At(Row(index)) => (ops.item)(draft, index).unwrap_or_default(),
Slot::New(list) => (list.blank)(&draft.plan),
};
let snapshot = applies::opened(ops, &pristine);
Self {
ops,
table,
slot,
form: None,
stale: applies::stale(ops, &pristine, &snapshot),
pristine,
snapshot,
incomplete: BTreeMap::new(),
is_seeded: false,
takes_keyboard: false,
is_alone: false,
}
}
fn stored_at(&self, draft: &Draft) -> usize {
match self.slot {
Slot::At(Row(index)) => index,
Slot::New(list) => (list.count)(&draft.plan),
}
}
pub(super) fn is_dirty(&self) -> bool {
self.held_back().is_some() || self.written() != self.stripped(self.pristine.clone())
}
pub(super) fn title(&self) -> String {
let Some(list) = self.ops.list else {
return self.ops.title.to_owned();
};
let name = super::offers::display_name(&self.snapshot, list.identity, self.ops.fields);
match (self.slot, name) {
(Slot::New(_), _) => format!("New {}", list.singular),
(Slot::At(_), Some(name)) => format!("Edit {name}"),
(Slot::At(_), None) => format!("Edit {}", list.singular),
}
}
}
#[derive(Component, Default, Debug)]
pub struct ItemForm;
const MOVED_UNDERNEATH: &str =
"the plan changed under this edit; discard it and open the item again";
pub(super) const ITEM_HINTS: Hints = Hints(&[("⇥", "next"), ("⏎", "apply"), ("esc", "close")]);
const ALONE_HINTS: Hints = Hints(&[("⏎", "edit")]);
impl EditSession {
pub fn is_dirty(&self) -> bool {
self.0.as_ref().is_some_and(Editing::is_dirty)
}
pub fn editing_table(&self) -> Option<Entity> {
self.0.as_ref().and_then(|editing| editing.table)
}
pub fn is_over(&self, surface: Option<Page>) -> bool {
self.0
.as_ref()
.is_some_and(|editing| editing.ops.surface == surface)
}
pub fn is_open(&self) -> bool {
self.0.is_some()
}
pub fn close(&mut self) {
self.0 = None;
}
}
pub fn open_item(
In((ops, table, slot)): In<(Ops, Option<Entity>, Slot)>,
(draft, shell): (Res<Draft>, Res<Session>),
mut state: SessionFocus,
) {
if ops.target == Target::Draft
&& let Some(Outcome::Refused(reason)) = draft.refuse_if_read_only()
{
journal::warn(reason);
return;
}
let mut editing = Editing::new(ops, table, slot, &draft);
editing.takes_keyboard = true;
editing.is_alone = shell.is_empty();
state.session.0 = Some(editing);
}
pub fn sync_item_form(
mut session: ResMut<EditSession>,
mut standing: Standing<ItemForm>,
mut commands: Commands,
) {
if !session.is_changed() {
return;
}
let Some(editing) = session.bypass_change_detection().0.as_mut() else {
if standing.is_open() {
standing.close(&mut commands);
}
return;
};
if editing.form.is_some() {
return;
}
let Some(form) = standing.open(&mut commands) else {
return;
};
editing.form = Some(form);
let hints = if editing.is_alone {
ALONE_HINTS
} else {
ITEM_HINTS
};
commands.entity(form).insert((
build::centred(editing.ops),
Framed::over(editing.title()),
ModalOpen,
hints,
));
if editing.is_alone {
commands.entity(form).remove::<KeyScope>();
}
build::spawn_form(&mut commands, form, editing.ops, editing.is_alone);
}
fn resolve(In(applies): In<bool>, mut state: SessionFocus, mut editor: DraftEditor) {
if applies {
state.apply(None, &mut editor);
} else {
state.discard();
}
}
#[derive(SystemParam)]
pub struct SessionFocus<'w, 's> {
pub(super) session: ResMut<'w, EditSession>,
focus: ResMut<'w, InputFocus>,
overlays: ResMut<'w, overlay::Focus>,
pub(super) tables: Query<'w, 's, &'static mut DomainTable>,
pub confirm: ResMut<'w, Confirm>,
}
impl SessionFocus<'_, '_> {
pub fn holds_back(&mut self) -> bool {
let Some(editing) = self.session.0.as_ref().filter(|held| held.is_dirty()) else {
return false;
};
let answer = |applies: bool| {
move |commands: &mut Commands| {
commands.run_system_cached_with(resolve, applies);
}
};
let resumed = self.focus.get();
let resume = move |commands: &mut Commands| {
commands.queue(move |world: &mut World| {
world.resource_scope(|world, mut overlays: Mut<overlay::Focus>| {
overlays.rebase(resumed, &mut world.resource_mut::<InputFocus>());
});
});
};
self.confirm.ask_among(
format!("{} has changes that were not applied.", editing.title()),
vec![
Answer::running("Cancel", resume),
Answer::running("Discard", answer(false)).destructive(),
Answer::running("Apply", answer(true)).primary(),
],
);
true
}
pub(super) fn focus(&mut self, entity: Entity) {
self.overlays.rebase(Some(entity), &mut self.focus);
}
pub(super) fn enter(&mut self, entity: Entity) {
self.focus.set(entity, FocusCause::Navigated);
}
pub(super) fn apply(&mut self, label: Option<&str>, editor: &mut DraftEditor) -> bool {
let Some(editing) = self.session.0.as_mut() else {
return false;
};
let target = editing.ops.target;
if target == Target::Draft
&& let Some(Outcome::Refused(reason)) = editor.draft.refuse_if_read_only()
{
journal::warn(reason);
return false;
}
if !editing.is_dirty() && editing.slot.row().is_some() {
self.close();
return true;
}
if let Some(complaint) = editing.complaint() {
journal::warn(complaint);
return false;
}
let draft = editor.draft.bypass_change_detection();
if let Slot::At(Row(index)) = editing.slot
&& (editing.ops.item)(draft, index).as_ref() != Some(&editing.pristine)
{
journal::warn(format!("{}: {MOVED_UNDERNEATH}", editing.title()));
return false;
}
let index = editing.stored_at(draft);
let written = editing.written();
if let Err(message) = (editing.ops.store)(draft, index, written.clone()) {
let ops = editing.ops;
let named = label.unwrap_or_else(|| ops.list.map_or(ops.title, |list| list.singular));
journal::warn(format!("{named}: {message}"));
return false;
}
editing.pristine = written;
editing.slot = Slot::At(Row(index));
if let Some(mut table) = editing
.table
.and_then(|table| self.tables.get_mut(table).ok())
{
table.wanted = Some(Row(index));
table.is_applied = true;
}
if target == Target::Draft {
editor.commit();
}
self.close();
true
}
pub(super) fn leave(&mut self) {
if !self.holds_back() {
self.discard();
}
}
pub(super) fn discard(&mut self) {
if let Some(editing) = self.session.0.as_mut() {
editing.snapshot = applies::opened(editing.ops, &editing.pristine);
editing.incomplete.clear();
editing.is_seeded = false;
}
self.close();
}
fn close(&mut self) {
self.session.0 = None;
}
}