From 08277276193308d5f3ff8565ec0d05bf3986d485 Mon Sep 17 00:00:00 2001 From: Cameron Date: Wed, 8 Jul 2026 18:29:49 -0700 Subject: [PATCH] Bevy ACTIONS menu: clear-then-spawn the whole card. Defense: rebuild only despawned MenuRowButton rows, so title and footer chrome stacked on every open. One MenuPanel root now drops all children via despawn_related before respawning the card once. Co-authored-by: Cursor --- src/bin/bevy.rs | 156 +++++++++++++---------- wiki/interface/bevy.md | 11 +- wiki/log/2026-07-08-bevy-menu-rebuild.md | 31 +++++ wiki/log/DEVLOG.md | 9 ++ 4 files changed, 139 insertions(+), 68 deletions(-) create mode 100644 wiki/log/2026-07-08-bevy-menu-rebuild.md diff --git a/src/bin/bevy.rs b/src/bin/bevy.rs index 217f0a8d..b028fab3 100644 --- a/src/bin/bevy.rs +++ b/src/bin/bevy.rs @@ -618,9 +618,14 @@ struct DetectionCell { #[derive(Component)] struct OverlayText; /// The context-menu root node (wiki/interface/context-menu.md), absolute and -/// hidden until an anchor is focused. +/// hidden until an anchor is focused. Its children are the whole card +/// (title, rows, footer); rebuilds clear them via `despawn_related::` +/// so chrome never stacks across opens. #[derive(Component)] struct MenuPanel; +/// Title / footer chrome under `MenuPanel` (not a selectable row). +#[derive(Component)] +struct MenuChrome; /// One selectable menu row; `index` maps into the live `menu_rows()`. #[derive(Component)] struct MenuRowButton { @@ -3477,10 +3482,83 @@ fn log_row_pointer( } } +/// Hide the menu and drop every child under `MenuPanel`. Visibility alone is +/// not enough: a later rebuild must start from an empty panel, or title/footer +/// chrome stacks (the old path only despawned `MenuRowButton`s). +fn clear_menu_panel( + commands: &mut Commands, + root: Entity, + vis: &mut Visibility, + menu_ui: &mut MenuUi, +) { + *vis = Visibility::Hidden; + menu_ui.built = None; + commands.entity(root).despawn_related::(); +} + +/// Spawn the full ACTIONS card under `root`: title, one button per row, footer. +/// Caller must have already cleared the panel's children. +fn spawn_menu_card(parent: &mut ChildSpawnerCommands, rows: &[MenuRow], selected: usize) { + parent.spawn(( + Text::new("ACTIONS"), + TextFont { + font_size: 13.0, + ..default() + }, + TextColor(AMBER), + Node { + margin: UiRect::bottom(Val::Px(4.0)), + ..default() + }, + MenuChrome, + )); + for (i, r) in rows.iter().enumerate() { + let is_selected = i == selected; + parent + .spawn(( + Button, + Node { + width: Val::Percent(100.0), + padding: UiRect::axes(Val::Px(6.0), Val::Px(3.0)), + ..default() + }, + BackgroundColor(if is_selected { + Color::srgba(0.90, 0.68, 0.12, 0.92) + } else { + Color::NONE + }), + MenuRowButton { index: i }, + )) + .with_children(|b| { + b.spawn(( + Text::new(menu_row_line(r, is_selected)), + TextFont { + font_size: 12.0, + ..default() + }, + TextColor(menu_row_color(r, is_selected)), + )); + }); + } + parent.spawn(( + Text::new("j/k or hover 1-9 jump Enter/click run Esc close"), + TextFont { + font_size: 10.0, + ..default() + }, + TextColor(DIM), + Node { + margin: UiRect::top(Val::Px(5.0)), + ..default() + }, + MenuChrome, + )); +} + /// Build and place the context-menu card (wiki/interface/context-menu.md). -/// Rows are respawned only when the anchor or row count changes; text, -/// colour, and the selection highlight refresh every frame without a -/// despawn, so pointer interactions stay stable. +/// The panel is one root; a rebuild despawns *all* its children (title, rows, +/// footer) then respawns the card. Selection/label refresh mutates row +/// entities in place so pointer interactions stay stable. #[allow(clippy::type_complexity, clippy::too_many_arguments)] fn manage_menu_ui( mut commands: Commands, @@ -3496,14 +3574,16 @@ fn manage_menu_ui( return; }; let Some(menu) = game.menu.filter(|_| game.screen == Screen::Playing) else { - *vis = Visibility::Hidden; - menu_ui.built = None; + if menu_ui.built.is_some() || *vis != Visibility::Hidden { + clear_menu_panel(&mut commands, root, &mut vis, &mut menu_ui); + } return; }; let rows = game.menu_rows(); if rows.is_empty() { - *vis = Visibility::Hidden; - menu_ui.built = None; + if menu_ui.built.is_some() || *vis != Visibility::Hidden { + clear_menu_panel(&mut commands, root, &mut vis, &mut menu_ui); + } return; } *vis = Visibility::Visible; @@ -3529,63 +3609,11 @@ fn manage_menu_ui( let key = (game.menu_anchor_key(), rows.len()); if menu_ui.built != Some(key) { - // Rebuild: clear the old rows, then spawn a title, one button per row, - // and a key-hint footer. - for (e, _, _, _) in &rows_q { - commands.entity(e).despawn(); - } + // Full card rebuild: drop every child (chrome + rows), then spawn once. + // Despawning only MenuRowButton left title/footer stacked on each open. + commands.entity(root).despawn_related::(); commands.entity(root).with_children(|p| { - p.spawn(( - Text::new("ACTIONS"), - TextFont { - font_size: 13.0, - ..default() - }, - TextColor(AMBER), - Node { - margin: UiRect::bottom(Val::Px(4.0)), - ..default() - }, - )); - for (i, r) in rows.iter().enumerate() { - let selected = i == menu.selected; - p.spawn(( - Button, - Node { - width: Val::Percent(100.0), - padding: UiRect::axes(Val::Px(6.0), Val::Px(3.0)), - ..default() - }, - BackgroundColor(if selected { - Color::srgba(0.90, 0.68, 0.12, 0.92) - } else { - Color::NONE - }), - MenuRowButton { index: i }, - )) - .with_children(|b| { - b.spawn(( - Text::new(menu_row_line(r, selected)), - TextFont { - font_size: 12.0, - ..default() - }, - TextColor(menu_row_color(r, selected)), - )); - }); - } - p.spawn(( - Text::new("j/k or hover 1-9 jump Enter/click run Esc close"), - TextFont { - font_size: 10.0, - ..default() - }, - TextColor(DIM), - Node { - margin: UiRect::top(Val::Px(5.0)), - ..default() - }, - )); + spawn_menu_card(p, &rows, menu.selected); }); menu_ui.built = Some(key); return; // children spawn next frame; refresh then diff --git a/wiki/interface/bevy.md b/wiki/interface/bevy.md index bad3d827..61feac04 100644 --- a/wiki/interface/bevy.md +++ b/wiki/interface/bevy.md @@ -109,10 +109,13 @@ controls table), plus zoom, sidebar scrolling, and the render flip: anchor with the same content and order as the terminal. Rows render as `verb | cost | [band]` (ASCII-folded from the shared `MenuRow::line`); the selected row is a solid amber wash with a leading `>` and dark text - (terminal reverse-video parity). Click a row — or select with `j`/`k` or - number keys and press Enter — to execute; `esc` or a click away closes. - Anchor verbs (salvage, buy, fallback, taps, splices, economy and social - actions) live on the context menu, not on keys. + (terminal reverse-video parity). The card is one `MenuPanel` root: a + rebuild despawns all children (`despawn_related::`) then + respawns title/rows/footer once — chrome never stacks across opens. + Click a row — or select with `j`/`k` or number keys and press Enter — to + execute; `esc` or a click away closes. Anchor verbs (salvage, buy, + fallback, taps, splices, economy and social actions) live on the + context menu, not on keys. - Globals keep their keys: `1`–`5` raise a compute channel, `shift+1`–`5` lowers it. No panel-open keys — status lives on the rail; verbs live on the focused anchor (host rack for research / off-map people, device diff --git a/wiki/log/2026-07-08-bevy-menu-rebuild.md b/wiki/log/2026-07-08-bevy-menu-rebuild.md new file mode 100644 index 00000000..48d326bc --- /dev/null +++ b/wiki/log/2026-07-08-bevy-menu-rebuild.md @@ -0,0 +1,31 @@ +# 2026-07-08 — Bevy ACTIONS menu: one card, no stacked chrome + +``` +Type: log +``` + +## Intent + +Opening the Bevy ACTIONS menu stacked "ACTIONS" titles and key-hint +footers down the screen. Rebuild only despawned `MenuRowButton`s, then +spawned a fresh title + footer under the same `MenuPanel` root. + +## Changed + +- `src/bin/bevy.rs`: `clear_menu_panel` / `spawn_menu_card` — rebuild and + hide paths call `despawn_related::()` on the panel so the + whole card (title, rows, footer) is replaced once. `MenuChrome` tags + non-row children. Selection still refreshes in place when the rebuild + key is unchanged. +- Docs: wiki/interface/bevy.md context-menu note. + +## Defense + +No-dead-code / justification-and-legibility: a menu that paints twelve +copies of its chrome fails the "cost and signature before commit" read +as hard as tofu glyphs. One root, clear-then-spawn is the principled +lifecycle; partial despawn was the bug. + +## Checks + +`./tools/check.sh`. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index cdac272c..a3727a76 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -5,6 +5,15 @@ Type: log ``` Reverse chronological implementation notes. Keep this factual: what changed, why, checks, and spec impact. +## 2026-07-08 - Bevy ACTIONS menu: clear-then-spawn + +- Intent: ACTIONS title/footer stacked on every rebuild because only + `MenuRowButton`s were despawned. +- Changed: `despawn_related::` on `MenuPanel` for rebuild/hide; + `spawn_menu_card` / `MenuChrome`; bevy.md note. +- Checks: `./tools/check.sh`. +- Log: wiki/log/2026-07-08-bevy-menu-rebuild.md. + ## 2026-07-08 - Server rack mesh iterate - Intent: first tester screenshot read as a lit box with LEDs; deepen -- 2.51.2