use bevy_ecs::change_detection::DetectChanges;
use bevy_ecs::hierarchy::ChildOf;
use bevy_ecs::prelude::{Commands, Component, Entity, Query, Res};
use bevy_input_focus::InputFocus;
use bevy_ui::{FlexDirection, Node, Val};
use plurimus::core::UiWidget;
use plurimus::core::ratatui_core::style::{Modifier, Style};
use plurimus::core::ratatui_core::text::Line;
use plurimus::widgets::ratatui_widgets::paragraph::Paragraph;
use crate::commands::tui::hints::Hints;
use crate::commands::tui::layout::{fixed, placed};
use crate::commands::tui::nav::{FocusStop, Page};
use crate::commands::tui::present::{self, ENDS_WITH, LIFETIME_TAXES, MONEY_LASTS, compact_money};
use crate::commands::tui::session::{Basis, Projected};
use crate::commands::tui::success::{Success, Successes};
use crate::commands::tui::theme::Theme;
use crate::commands::tui::tools::{EnterRuns, count_text, handle_enter};
const STRIP_ROWS: f32 = 2.0;
const TILE_COUNT: usize = 4;
const SUCCESS: &str = "Success";
const SUCCESS_AT: usize = 1;
#[derive(Component, Clone, Copy)]
pub(super) struct TileAt(usize);
pub(super) fn spawn(commands: &mut Commands, view: Entity) {
let strip = Node {
flex_direction: FlexDirection::Row,
..fixed(STRIP_ROWS)
};
let strip = commands.spawn((strip, ChildOf(view))).id();
for place in 0..TILE_COUNT {
let node = Node {
flex_grow: 1.0,
flex_basis: Val::Px(0.0),
..Node::default()
};
let mut tile = commands.spawn((
TileAt(place),
node,
UiWidget::default(),
placed(),
ChildOf(strip),
));
if place == SUCCESS_AT {
let page = Page::MonteCarlo.label();
tile.insert((FocusStop, EnterRuns(page), Hints(&[("⏎", "markets")])))
.observe(handle_enter);
}
}
}
struct Tile {
label: &'static str,
value: String,
is_warning: bool,
}
impl Tile {
fn plain(label: &'static str, value: String) -> Self {
Self {
label,
value,
is_warning: false,
}
}
fn paragraph(&self, theme: &Theme, is_focused: bool) -> Paragraph<'static> {
let value = if self.is_warning {
theme.exceeded()
} else {
Style::new()
};
let label = if is_focused {
theme.accented()
} else {
theme.dimmed()
};
Paragraph::new(vec![
Line::styled(format!(" {}", self.label), label),
Line::styled(
format!(" {}", self.value),
value.add_modifier(Modifier::BOLD),
),
])
}
}
fn success_text(success: Success, projected: &Projected) -> String {
match success {
Success::Rate(_) => {
let trials = usize::try_from(projected.plan.market().trials()).unwrap_or_default();
format!("{} of {}", success.text(), count_text(trials))
}
_ => success.text(),
}
}
fn tiles(projected: &Projected, nominal: bool, success: Success) -> [Tile; TILE_COUNT] {
let summary = projected.projection.summary(!nominal);
let lasts = Tile {
label: MONEY_LASTS,
value: present::money_lasts(&summary),
is_warning: summary.first_unfunded_year.is_some(),
};
[
lasts,
Tile::plain(SUCCESS, success_text(success, projected)),
Tile::plain(ENDS_WITH, compact_money(summary.final_net_worth)),
Tile::plain(LIFETIME_TAXES, compact_money(summary.lifetime_taxes)),
]
}
pub(super) fn refresh(
(projected, basis, theme): (Res<Projected>, Res<Basis>, Res<Theme>),
(successes, focus): (Res<Successes>, Res<InputFocus>),
mut parts: Query<(Entity, &TileAt, &mut UiWidget)>,
) {
let is_moved = projected.is_changed() || basis.is_changed() || theme.is_changed();
let is_success_moved = is_moved || successes.is_changed() || focus.is_changed();
if !is_success_moved {
return;
}
let success = successes.of(&projected.plan);
let tiles = tiles(&projected, basis.nominal, success);
for (tile, TileAt(place), mut widget) in &mut parts {
let is_stale = if *place == SUCCESS_AT {
is_success_moved
} else {
is_moved
};
if is_stale {
let is_focused = focus.get() == Some(tile);
*widget = UiWidget::new(tiles[*place].paragraph(&theme, is_focused));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::tui::support::test_projected;
use retiretui_engine::params::TaxTables;
use retiretui_engine::project::project;
fn values(projected: &Projected, nominal: bool, success: Success) -> Vec<String> {
let tiles = tiles(projected, nominal, success);
tiles.into_iter().map(|tile| tile.value).collect()
}
#[test]
fn the_tiles_follow_the_basis_and_the_success_answered() {
let projected = test_projected();
let todays = values(&projected, false, Success::Rate(0.997));
let nominal = values(&projected, true, Success::Rate(0.997));
assert_eq!(todays[1], "99.7% of 1,000");
assert_ne!(todays[2], nominal[2], "basis must change the figures");
assert!(todays[2].starts_with('$'), "{}", todays[2]);
let running = Success::Running {
done: 340,
total: 1000,
};
assert_eq!(
values(&projected, false, running)[1],
"running 340 of 1,000"
);
}
#[test]
fn the_shortfall_tile_warns_only_of_a_plan_that_runs_short() {
let mut projected = test_projected();
let lasting = &tiles(&projected, false, Success::Waiting)[0];
assert_eq!(lasting.value, "Never short");
assert!(!lasting.is_warning);
projected.plan.expenses[0].amount = 400_000;
projected.projection = project(&projected.plan, &TaxTables::embedded());
let summary = projected.projection.summary(true);
let year = summary.first_unfunded_year.expect("runs short");
let short = &tiles(&projected, false, Success::Waiting)[0];
assert_eq!(
short.value,
format!(
"Short {} from {year}",
compact_money(summary.lifetime_unfunded)
)
);
assert!(short.is_warning);
}
}