mod assumptions;
mod chart;
mod historical;
mod monte_carlo;
mod views;
#[cfg(test)]
mod tests;
use bevy_app::{App, Update};
use bevy_ecs::change_detection::DetectChanges;
use bevy_ecs::entity::Entity;
use bevy_ecs::hierarchy::ChildOf;
use bevy_ecs::prelude::{Commands, IntoScheduleConfigs, Local, Query, Res, ResMut, Resource};
use bevy_ui::{FlexDirection, Node, Val};
use plurimus::core::UiWidget;
use plurimus::core::ratatui_core::style::Style;
use retiretui_engine::market::{History, Progress, Run, RunError, Runs};
use retiretui_engine::params::TaxTables;
use retiretui_engine::plan::{Issue, Plan};
use retiretui_engine::project::Projection;
pub(crate) use assumptions::edit_assumption;
pub(crate) use views::cycle_view;
use super::options::spawn_table;
use super::{Found, HelpLine, ResultPane, Tool, count_text, show_help};
use crate::commands::tui::edit::Draft;
use crate::commands::tui::hints::Hints;
use crate::commands::tui::nav::{ActivePage, FocusStop, Page};
use crate::commands::tui::pane::{Framed, Pane};
use crate::commands::tui::present::{self, compact_dollars};
use crate::commands::tui::session::Session;
use crate::commands::tui::theme::Theme;
#[derive(Resource)]
pub struct MarketHistory(pub History);
impl Default for MarketHistory {
fn default() -> Self {
Self(History::embedded().clone())
}
}
pub fn plugin(app: &mut App) {
app.init_resource::<MarketHistory>();
app.add_plugins((monte_carlo::plugin, historical::plugin));
}
const GOOD_ZONE: f64 = 0.9;
const CAUTION_ZONE: f64 = 0.75;
const RUNS_ROWS: f32 = 10.0;
const ASSUMPTIONS_COLS: f32 = 34.0;
const NEVER: &str = "never";
const NOTHING_SEARCHED: &str = "Runs by itself while this page is shown.";
pub(crate) trait MarketTool: Found + Sized {
const PAGE: Page;
const RUN_HEADING: &'static str;
const HELP: &'static str;
const TOOL_PAGE: &'static super::ToolPage = &super::ToolPage {
surface: Self::PAGE,
panes: spawn_panes::<Self>,
};
const OPEN: &'static str;
const EDIT: &'static str;
const VIEW: &'static str;
const VERDICT: &'static str;
const HEADLINE: &'static str;
const HAS_BY_YEAR: bool;
fn search(
plan: &Plan,
tables: &TaxTables,
history: &History,
progress: &Progress,
) -> Result<Self, RunError>;
fn total(plan: &Plan) -> usize;
fn runs(&self) -> &Runs;
fn listed(&self) -> Vec<(String, &Run)>;
fn ledger_label(first: &str) -> String;
fn verdict(&self) -> String;
fn settings(plan: &Plan) -> Vec<assumptions::Assumption>;
}
fn issues_of(error: RunError) -> Vec<Issue> {
match error {
RunError::Refused(issues) => issues,
RunError::Cancelled => Vec::new(),
}
}
fn install<R: MarketTool>(app: &mut App) {
super::install::<R>(app, R::TOOL_PAGE);
super::options::plugin::<R>(app);
app.add_systems(
Update,
(search_by_itself::<R>, title_runs::<R>, say_help::<R>).after(super::poll_search::<R>),
);
assumptions::install::<R>(app);
chart::install::<R>(app);
views::install::<R>(app);
}
fn search_by_itself<R: MarketTool>(
(draft, session, history): (Res<Draft>, Res<Session>, Res<MarketHistory>),
active: Res<ActivePage>,
mut searched: Local<Option<Plan>>,
mut tool: ResMut<Tool<R>>,
) {
let is_ready = (draft.is_changed() || active.is_changed()) && active.0 == R::PAGE;
if !is_ready || !super::is_due(&draft, searched.as_ref(), |plan| *plan == draft.plan) {
return;
}
*searched = Some(draft.plan.clone());
let tables = session.tables.clone();
let history = history.0.clone();
let total = R::total(&draft.plan);
tool.restart(draft.plan.clone(), total, move |plan, progress| {
R::search(plan, &tables, &history, progress).map_err(issues_of)
});
}
fn title_runs<R: MarketTool>(
tool: Res<Tool<R>>,
mut panes: Query<&mut Framed, bevy_ecs::prelude::With<ResultPane<R>>>,
) {
if !tool.is_changed() {
return;
}
let title = tool.found().map_or_else(
|| R::RUN_HEADING.to_owned(),
|found| format!("{} · {} {}", R::RUN_HEADING, R::HEADLINE, found.verdict()),
);
for mut pane in &mut panes {
Framed::retitle(&mut pane, &title);
}
}
fn say_help<R: MarketTool>(
active: Res<ActivePage>,
theme: Res<Theme>,
mut lines: Query<(&mut UiWidget, &HelpLine)>,
) {
if (active.is_changed() || theme.is_changed()) && active.0 == R::PAGE {
show_help(&mut lines, R::PAGE, R::HELP, &theme);
}
}
fn zone_style(share: f64, theme: &Theme) -> Style {
if share >= GOOD_ZONE {
Style::new().fg(theme.good)
} else if share >= CAUTION_ZONE {
Style::new().fg(theme.caution)
} else {
theme.exceeded()
}
}
fn run_cells(plan: &Plan, first: String, run: &Run) -> Vec<String> {
let short = run.first_short.map_or_else(
|| NEVER.to_owned(),
|year| match plan.household.people.first() {
Some(person) => format!("{year} ({})", person.age_in_year(year)),
None => year.to_string(),
},
);
vec![first, compact_dollars(run.ending), short]
}
fn laid<R: MarketTool>(found: &R, plan: &Plan) -> super::options::Laid {
let runs = found.runs();
super::options::Laid {
header: [R::RUN_HEADING, "Ends with", "Short in"]
.map(str::to_owned)
.to_vec(),
current: run_cells(plan, "As planned".to_owned(), &runs.planned),
options: found
.listed()
.into_iter()
.map(|(first, run)| run_cells(plan, first, run))
.collect(),
}
}
fn share_text(runs: &Runs) -> String {
present::rate(runs.success_rate())
}
fn spawn_panes<R: MarketTool>(commands: &mut Commands, row: Entity) {
assumptions::spawn_pane::<R>(commands, row, ASSUMPTIONS_COLS);
let column = Node {
flex_direction: FlexDirection::Column,
flex_grow: 1.0,
flex_basis: Val::Px(0.0),
height: Val::Percent(100.0),
..Node::default()
};
let column = commands.spawn((column, ChildOf(row))).id();
let framed = Pane::new(R::RUN_HEADING)
.tall(RUNS_ROWS)
.spawn(commands, column);
let hints = Hints(&[("↑↓", "run"), ("⏎", "open in ledger")]);
let table = spawn_table::<R>(commands, framed, R::OPEN, hints);
commands.entity(table).insert(FocusStop);
chart::spawn_pane::<R>(commands, column);
}
fn highlighted<R: MarketTool>(tool: &Tool<R>) -> Option<(String, &Run)> {
let at = tool.highlighted()?;
tool.found()?.listed().into_iter().nth(at)
}
pub(crate) fn open_run<R: MarketTool>(
(tool, draft, session, history): (Res<Tool<R>>, Res<Draft>, Res<Session>, Res<MarketHistory>),
mut run: ResMut<crate::commands::tui::session::LedgerRun>,
mut active: ResMut<ActivePage>,
) -> crate::commands::tui::command::Outcome {
use crate::commands::tui::command::Outcome;
let Some((label, chosen)) = highlighted(&*tool) else {
return Outcome::Refused(super::NOTHING_SEARCHED_YET.to_owned());
};
let replayed: Option<Projection> =
retiretui_engine::market::replay(&draft.plan, &session.tables, &history.0, chosen.name);
let Some(projection) = replayed else {
return Outcome::Refused(format!("{label} cannot be replayed"));
};
let projected = crate::commands::tui::session::Projected {
plan: draft.plan.clone(),
projection,
};
run.0 = Some((R::ledger_label(&label), projected));
active.0 = Page::Ledger;
Outcome::Done
}