use bevy_app::{App, Update};
use bevy_ecs::change_detection::DetectChanges;
use bevy_ecs::hierarchy::{ChildOf, Children};
use bevy_ecs::prelude::{
Commands, Component, Entity, IntoScheduleConfigs, On, Query, Res, ResMut, Resource, With,
};
use bevy_ecs::system::SystemParam;
use bevy_input::keyboard::KeyboardInput;
use bevy_input_focus::FocusedInput;
use plurimus::core::ratatui_core::text::Line;
use plurimus::term::bevy_compat::HeldModifiers;
use plurimus::ui::{ModalDismiss, ValueChange, first_bound};
use plurimus::widgets::{ActiveDescendant, list_item};
use retiretui_engine::plan::Issue;
use super::command::Outcome;
use super::edit::{self, Draft, Turn};
use super::hints::Hints;
use super::journal;
use super::overlay::{self, Standing};
use super::picker;
use super::theme::Theme;
pub fn plugin(app: &mut App) {
app.init_resource::<Issues>();
app.add_systems(Update, (sync_issues, refresh_issues).in_set(picker::Synced));
}
const TITLE: &str = "Issues";
const NO_ISSUES: &str = "no issues";
const HINTS: Hints = Hints(&[("⏎", "go"), ("esc", "close")]);
#[derive(Resource, Default)]
pub struct Issues(bool);
#[derive(Component, Default, Debug)]
struct IssuesRoot;
#[derive(Component)]
struct IssueList;
#[derive(Component, Clone, Copy)]
struct IssueRow(usize);
pub fn toggle(mut issues: ResMut<Issues>) -> Outcome {
issues.0 = !issues.0;
Outcome::Done
}
fn sync_issues(
issues: Res<Issues>,
draft: Res<Draft>,
theme: Res<Theme>,
mut standing: Standing<IssuesRoot>,
mut commands: Commands,
) {
if !issues.is_changed() {
return;
}
if !issues.0 {
standing.close(&mut commands);
return;
}
let Some(root) = standing.open(&mut commands) else {
return;
};
let list = overlay::bottom_panel(&mut commands, root, TITLE, HINTS);
commands
.entity(root)
.observe(handle_key)
.observe(handle_dismiss);
commands
.entity(list)
.insert(IssueList)
.observe(handle_chosen);
spawn_rows(&mut commands, list, &draft, &theme);
standing.focus(list);
}
fn refresh_issues(
draft: Res<Draft>,
theme: Res<Theme>,
lists: Query<Entity, With<IssueList>>,
mut commands: Commands,
) {
if draft.is_changed()
&& let Ok(list) = lists.single()
{
commands.entity(list).despawn_related::<Children>();
spawn_rows(&mut commands, list, &draft, &theme);
}
}
fn spawn_rows(commands: &mut Commands, list: Entity, draft: &Draft, theme: &Theme) {
let mut first = None;
for (index, issue) in draft.issues().iter().enumerate() {
let line = Line::styled(edit::issue_words(issue, draft), theme.exceeded());
let row = commands
.spawn((IssueRow(index), list_item(line), ChildOf(list)))
.id();
first.get_or_insert(row);
}
if first.is_none() {
let line = Line::styled(NO_ISSUES, theme.dimmed());
commands.spawn((list_item(line), ChildOf(list)));
}
commands.entity(list).insert(ActiveDescendant(first));
}
#[derive(SystemParam)]
struct Jump<'w, 's> {
rows: Query<'w, 's, &'static IssueRow>,
draft: Res<'w, Draft>,
issues: ResMut<'w, Issues>,
turn: Turn<'w, 's>,
}
impl Jump<'_, '_> {
fn issue_of(&self, row: Entity) -> Option<&Issue> {
self.draft.issues().get(self.rows.get(row).ok()?.0)
}
fn go(&mut self, row: Entity) {
let Some(issue) = self.issue_of(row) else {
return;
};
let Some((page, index)) = edit::issue_target(&issue.path) else {
journal::warn(format!("{}: no page shows it", issue.path));
return;
};
self.issues.0 = false;
self.turn.to(page, index);
}
}
fn handle_key(
mut input: On<FocusedInput<KeyboardInput>>,
held: HeldModifiers,
mut issues: ResMut<Issues>,
) {
if first_bound(overlay::CLOSE_KEYS, &input.input, held.get()).is_some() {
input.propagate(false);
issues.0 = false;
}
}
fn handle_chosen(chosen: On<ValueChange<Entity>>, mut jump: Jump) {
jump.go(chosen.value);
}
fn handle_dismiss(_dismissed: On<ModalDismiss>, mut issues: ResMut<Issues>) {
issues.0 = false;
}
#[cfg(test)]
mod tests {
use bevy_app::App;
use plurimus::term::KeyCode;
use plurimus::widgets::ActiveDescendant;
use retiretui_engine::plan::Plan;
use super::*;
use crate::commands::tui::edit::Row;
use crate::commands::tui::edit::tests::cursor;
use crate::commands::tui::nav::Page;
use crate::commands::tui::support::{
SIZE, active_page as active, commit_edit, composed_frame, headless_app, press_key, redrawn,
};
fn is_open(app: &App) -> bool {
app.world().resource::<Issues>().0
}
fn count(app: &App) -> usize {
app.world().resource::<Draft>().issues().len()
}
fn break_the_start_year(plan: &mut Plan) {
plan.plan.start_year = 1000;
}
#[test]
fn issue_paths_map_to_pages_by_their_longest_root() {
let cases = [
("accounts[2].locked_until", Some((Page::Accounts, Some(2)))),
("cliffs[10]", Some((Page::Cliffs, Some(10)))),
("household.people[0].birth", Some((Page::People, Some(0)))),
("household.people", Some((Page::People, None))),
("household.filing", Some((Page::Household, None))),
("medicare.prior_magi", Some((Page::Household, None))),
("plan.withdrawal_order", Some((Page::Settings, None))),
("options.sources[0]", None),
("accountsx[1]", None),
];
for (path, target) in cases {
assert_eq!(edit::issue_target(path), target, "{path}");
}
let mut plan = crate::commands::tui::support::test_projected().plan;
plan.accounts[1].balance = -1;
let issue = &plan.validate()[0];
assert_eq!(
edit::issue_target(&issue.path),
Some((Page::Accounts, Some(1))),
"{issue}"
);
}
#[test]
fn the_panel_lists_every_issue_and_enter_jumps_to_the_item() {
let mut app = headless_app(SIZE);
press_key(&mut app, KeyCode::Char('i'));
let frame = composed_frame(&app);
assert!(frame.contains(NO_ISSUES), "{frame}");
press_key(&mut app, KeyCode::Esc);
assert!(!is_open(&app));
commit_edit(&mut app, |plan| plan.accounts[1].balance = -1);
commit_edit(&mut app, break_the_start_year);
let before = count(&app);
assert!(before > 1);
let frame = redrawn(&mut app);
assert!(frame.contains(&format!("{before} issues")), "{frame}");
press_key(&mut app, KeyCode::Char('i'));
let frame = composed_frame(&app);
assert!(frame.contains("Accounts › k › Balance: must"), "{frame}");
assert!(frame.contains("Settings › Start year:"), "{frame}");
assert!(frame.contains("⏎ go"), "{frame}");
let account_row = {
let issues = app.world().resource::<Draft>().issues();
issues
.iter()
.position(|issue| issue.path.starts_with("accounts"))
.unwrap()
};
for _ in 0..account_row {
press_key(&mut app, KeyCode::Down);
}
press_key(&mut app, KeyCode::Enter);
assert!(!is_open(&app), "the jump closes the panel");
assert_eq!(active(&app), Page::Accounts);
app.update();
assert_eq!(cursor(&mut app), Row(1), "the cursor is on the item");
commit_edit(&mut app, |plan| plan.accounts[1].balance = 1);
assert_eq!(count(&app), before - 1);
let frame = redrawn(&mut app);
assert!(frame.contains(&format!("{} issue", before - 1)), "{frame}");
press_key(&mut app, KeyCode::Char('i'));
press_key(&mut app, KeyCode::Enter);
assert_eq!(active(&app), Page::Settings, "a single domain just turns");
}
#[test]
fn the_list_follows_the_draft_while_open() {
let mut app = headless_app(SIZE);
commit_edit(&mut app, break_the_start_year);
press_key(&mut app, KeyCode::Char('i'));
commit_edit(&mut app, |plan| plan.plan.start_year = 2026);
let frame = redrawn(&mut app);
assert!(frame.contains(NO_ISSUES), "{frame}");
let list = {
let mut lists = app
.world_mut()
.query_filtered::<&ActiveDescendant, With<IssueList>>();
lists.single(app.world()).unwrap().0
};
assert!(list.is_none(), "nothing to go to");
}
}