mod accounts;
mod applies;
mod build;
mod cells;
mod changes;
mod codec;
mod commands;
mod config;
mod contributions;
mod details;
mod domain;
mod draft;
mod editing;
mod expenses;
mod field;
mod flows;
mod form;
mod group;
mod household;
mod income;
mod market;
mod offers;
mod screen;
mod search;
mod select;
mod sort;
mod table;
#[cfg(test)]
pub(crate) mod tests;
mod trigger;
mod widths;
use std::cmp::Reverse;
use bevy_app::{App, Startup, Update};
use bevy_ecs::prelude::{Commands, Entity, IntoScheduleConfigs, Query, With, World};
use retiretui_engine::plan::Plan;
use toml::Table;
use super::hints::Hints;
use super::layout::{self, Body};
use super::nav::Page;
use super::overlay;
#[cfg(test)]
pub use build::EditForm;
pub use build::{FormButton, help_fits};
pub use changes::change_words;
pub use codec::from_table;
pub use commands::{Importing, add, delete, import_earnings, record_statement};
pub use domain::{FieldSpec, Ops, ToolAnswers};
pub use draft::{Draft, DraftEditor, redo, save, undo, write_draft};
pub use editing::{EditSession, Slot, open_item};
pub use offers::{RefSource, Vocabulary};
pub use sort::sort;
#[cfg(test)]
pub use table::DomainTable;
pub use table::{Row, Turn, table_bundle};
use super::session::Projected;
use super::watch::Watch;
const SCREENS: &[Ops] = &[
Ops::of::<accounts::Accounts>(),
Ops::of::<income::Incomes>(),
Ops::of::<expenses::Expenses>(),
Ops::of::<expenses::Cliffs>(),
Ops::of::<flows::Transfers>(),
Ops::of::<flows::Conversions>(),
Ops::of::<contributions::Contributions>(),
Ops::of::<flows::Events>(),
Ops::of::<household::People>(),
Ops::of::<household::Residencies>(),
Ops::single::<household::Household>(),
Ops::single::<config::Config>(),
Ops::single::<market::MarketSettings>(),
];
#[derive(bevy_ecs::prelude::SystemSet, Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum EditSystems {
Seed,
Place,
}
pub fn plugin(app: &mut App) {
app.init_resource::<Importing>();
app.configure_sets(Update, (EditSystems::Seed, EditSystems::Place).chain());
app.add_plugins((screen::plugin, form::plugin));
app.add_systems(
Startup,
(seed_draft, spawn_screens.after(layout::spawn_frame)),
);
}
pub fn seed_draft(world: &mut World) {
let plan = world.resource::<Projected>().plan.clone();
let is_scenario = world.resource::<Watch>().is_scenario();
world.insert_resource(Draft::new(plan, is_scenario));
}
fn spawn_screens(bodies: Query<Entity, With<Body>>, mut commands: Commands) {
let Ok(body) = bodies.single() else {
return;
};
for &ops in SCREENS {
screen::spawn_screen(&mut commands, body, ops);
}
}
pub fn lists_items(page: Page) -> bool {
SCREENS
.iter()
.any(|ops| ops.surface == Some(page) && ops.list.is_some())
}
pub fn item_count(page: Page, plan: &Plan) -> Option<usize> {
let list = SCREENS.iter().find(|ops| ops.surface == Some(page))?.list?;
Some((list.count)(plan))
}
pub const TALLEST_FORM_ROWS: u16 = tallest_form_rows();
const fn tallest_form_rows() -> u16 {
let mut tallest = 0;
let mut at = 0;
while at < SCREENS.len() {
let rows = build::rows_of(SCREENS[at]).saturating_add(overlay::CHROME);
if rows > tallest {
tallest = rows;
}
at += 1;
}
tallest
}
const _: () = {
let mut at = 0;
while at < SCREENS.len() {
assert!(
build::help_fits(SCREENS[at]),
"a field's help is missing or too long"
);
at += 1;
}
};
pub fn spawn_details(commands: &mut Commands, pane: Entity, ops: Ops) {
details::spawn_into(commands, pane, ops, None, TOOL_HINTS);
}
const TOOL_HINTS: Hints = Hints(&[("⏎", "edit")]);
struct Located {
ops: &'static Ops,
index: Option<usize>,
field: Option<&'static FieldSpec>,
}
fn locate(path: &str) -> Option<Located> {
let (ops, root) = SCREENS
.iter()
.flat_map(|ops| ops.paths.iter().map(move |root| (ops, *root)))
.filter(|(_, root)| is_at_or_within(path, root))
.max_by_key(|(_, root)| root.len())?;
let rest = &path[root.len()..];
let (index, within) = match rest.strip_prefix('[').and_then(|rest| rest.split_once(']')) {
Some((digits, within)) => (digits.parse().ok(), within),
None => (None, rest),
};
let keyed_root = root.rsplit('.').next().map_or(0, str::len);
let field = field_at(ops.fields, within.trim_start_matches('.'))
.or_else(|| field_at(ops.fields, &path[root.len() - keyed_root..]));
Some(Located { ops, index, field })
}
fn field_at<'a>(fields: &'a [FieldSpec], path: &str) -> Option<&'a FieldSpec> {
let holding = fields.iter().filter(|spec| is_at_or_within(path, spec.key));
holding
.min_by_key(|spec| Reverse(spec.key.len()))
.or_else(|| fields.iter().find(|spec| is_at_or_within(spec.key, path)))
}
fn is_at_or_within(path: &str, outer: &str) -> bool {
path == outer || codec::is_within(path, outer)
}
pub fn issue_target(path: &str) -> Option<(Page, Option<usize>)> {
let located = locate(path)?;
Some((located.ops.surface?, located.index))
}
const PLACE_SEPARATOR: &str = " \u{203a} ";
pub fn issue_words(issue: &retiretui_engine::plan::Issue, draft: &Draft) -> String {
let Some(located) = locate(&issue.path) else {
return issue.to_string();
};
let item = located
.index
.and_then(|index| (located.ops.item)(draft, index));
let place = place_words(&located, item.as_ref());
format!("{}: {}", place.join(PLACE_SEPARATOR), issue.message)
}
fn place_words(located: &Located, item: Option<&Table>) -> Vec<String> {
let identity = located.ops.list.map(|list| list.identity);
let item = item
.zip(identity)
.and_then(|(item, identity)| offers::display_name(item, identity, located.ops.fields));
let page = Some(located.ops.title.to_owned());
let field = located.field.map(|spec| spec.label.to_owned());
[page, item, field].into_iter().flatten().collect()
}
type LocatedIssue<'a> = (Located, &'a str);
fn located_issues(draft: &Draft) -> Vec<LocatedIssue<'_>> {
let issues = draft.issues().iter();
issues
.filter_map(|issue| Some((locate(&issue.path)?, issue.message.as_str())))
.collect()
}
fn field_issue<'a>(
located: &[LocatedIssue<'a>],
ops: Ops,
index: Option<usize>,
key: &str,
) -> Option<&'a str> {
let domain = ops.surface.filter(|page| page.is_domain())?;
located.iter().find_map(|(located, message)| {
let is_here = located.ops.surface == Some(domain)
&& located.index == index
&& located.field.is_some_and(|spec| spec.key == key);
is_here.then_some(*message)
})
}