From 24cacf112f3521ce0a2f7e14d23f2bc20162d567 Mon Sep 17 00:00:00 2001 From: Cameron Date: Fri, 10 Jul 2026 05:35:34 +0000 Subject: [PATCH] Implement ACTIONS status dials Host-rack human menus collapse mode/job/research/drift into bracketed dial rows; Enter opens a short picker; Esc backs out. Agent actions stays a flat dump. No new sim behavior. Defense: wiki/interface/context-menu.md status-dials criteria D1-D4 (ROADMAP #36). Sim::human_menu is the shared presentation; available_actions remains the legality source. Co-authored-by: Cursor --- src/actions.rs | 325 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--- src/bin/bevy.rs | 111 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------------- wiki/interface/context-menu.md | 18 +++++++++--------- wiki/log/2026-07-09-actions-menu-dials.md | 34 ++++++++++++++++------------------ wiki/log/DEVLOG.md | 8 ++++++++ wiki/process/ROADMAP.md | 7 +++++-- wiki/process/specs.md | 2 +- src/bin/terminal/mod.rs | 55 +++++++++++++++++++++++++++++++++++++++++-------------- src/bin/terminal/ui.rs | 40 ++++++++++++++++++---------------------- 9 file(s) changed, 493 insertion(s)(+), 107 deletion(s)(-) diff --git a/src/actions.rs b/src/actions.rs --- a/src/actions.rs +++ b/src/actions.rs @@ -212,8 +212,8 @@ } /// A flattened, render-ready menu row: automate affordances become -/// indented child rows of their verb so all three surfaces (terminal, -/// Bevy, agent mode) show identical content in identical order. +/// indented child rows of their verb. Agent mode uses [`menu_rows`] for a +/// flat dump; human frontends use [`Sim::human_menu`] (status dials). #[derive(Debug, Clone, PartialEq)] pub struct MenuRow { pub label: String, @@ -249,8 +249,100 @@ } } +/// Mutually exclusive standing choices collapsed on the human root menu +/// (wiki/interface/context-menu.md status dials). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum DialId { + Mode, + Job, + Research, + Drift, +} + +impl DialId { + pub const ALL: [DialId; 4] = [DialId::Mode, DialId::Job, DialId::Research, DialId::Drift]; + + pub fn name(self) -> &'static str { + match self { + DialId::Mode => "mode", + DialId::Job => "job", + DialId::Research => "research", + DialId::Drift => "drift", + } + } +} + +/// One selectable row on the human ACTIONS menu. +#[derive(Debug, Clone, PartialEq)] +pub enum HumanMenuRow { + /// Enter opens this dial's picker; no command. + Dial { id: DialId, label: String }, + /// Enter executes (or narrates a disabled reason). + Action(MenuRow), +} + +impl HumanMenuRow { + pub fn enabled(&self) -> bool { + match self { + HumanMenuRow::Dial { .. } => true, + HumanMenuRow::Action(r) => r.enabled(), + } + } + + pub fn indent(&self) -> bool { + match self { + HumanMenuRow::Dial { .. } => false, + HumanMenuRow::Action(r) => r.indent, + } + } + + /// Shared display contract for terminal/Bevy (indent dash for automate). + pub fn display_text(&self) -> String { + match self { + HumanMenuRow::Dial { label, .. } => label.clone(), + HumanMenuRow::Action(r) if r.indent => format!(" - {}", r.line()), + HumanMenuRow::Action(r) => r.line(), + } + } + + pub fn as_action(&self) -> Option<&MenuRow> { + match self { + HumanMenuRow::Action(r) => Some(r), + HumanMenuRow::Dial { .. } => None, + } + } + + pub fn as_dial(&self) -> Option { + match self { + HumanMenuRow::Dial { id, .. } => Some(*id), + HumanMenuRow::Action(_) => None, + } + } +} + +/// Which dial (if any) a top-level command belongs to. +fn dial_of(command: &ActionCommand) -> Option { + match command { + ActionCommand::SetMachineMode { .. } => Some(DialId::Mode), + ActionCommand::SetTarget(_) => Some(DialId::Job), + ActionCommand::SetResearchTrack(_) => Some(DialId::Research), + ActionCommand::SetMaskingPolicy(_) => Some(DialId::Drift), + _ => None, + } +} + +fn is_current_dial_reason(reason: Option<&str>) -> bool { + matches!( + reason, + Some("already in this mode") + | Some("already the active research job") + | Some("already the standing drift policy") + ) +} + /// Flatten descriptors into selectable rows (automate entries in place, -/// indented under their verb). +/// indented under their verb). Agent mode and tests use this flat dump; +/// human frontends use [`Sim::human_menu`]. pub fn menu_rows(actions: &[ActionDesc]) -> Vec { let mut rows = Vec::new(); for a in actions { @@ -278,6 +370,41 @@ rows } +fn action_to_menu_row(a: &ActionDesc, current: bool) -> MenuRow { + let mut label = a.verb.clone(); + let disabled = if current { + if !label.contains("current") { + label.push_str(" · current"); + } + None + } else { + a.disabled_reason.clone() + }; + MenuRow { + label, + cost: a.cost.label(), + signature: a.signature.as_ref().map(|s| s.label()), + disabled, + command: a.command.clone(), + indent: false, + active: current, + } +} + +fn push_automate_rows(rows: &mut Vec, a: &ActionDesc) { + if let Some(auto) = &a.automate { + rows.push(MenuRow { + label: format!("auto: {}", auto.verb), + cost: auto.cost.clone(), + signature: None, + disabled: None, + command: auto.command.clone(), + indent: true, + active: auto.active, + }); + } +} + impl Sim { /// The single legality source for the action surface: every verb /// executable on `anchor` right now, plus known-but-blocked verbs with @@ -288,6 +415,107 @@ Anchor::Device(id) => self.device_actions(id), Anchor::Person(id) => self.person_actions(id), Anchor::Flow(id) => self.flow_actions(id), + } + } + + /// Human ACTIONS presentation (status dials). `open_dial` None is the + /// root; Some opens that dial's picker. Agent mode keeps the flat + /// [`menu_rows`] dump — this is terminal/Bevy only. + pub fn human_menu(&self, anchor: Anchor, open_dial: Option) -> Vec { + let actions = self.available_actions(anchor); + match open_dial { + None => self.human_root_rows(&actions), + Some(dial) => self + .human_dial_rows(&actions, dial) + .into_iter() + .map(HumanMenuRow::Action) + .collect(), + } + } + + fn human_root_rows(&self, actions: &[ActionDesc]) -> Vec { + let mut rows = Vec::new(); + for dial in DialId::ALL { + if !actions.iter().any(|a| dial_of(&a.command) == Some(dial)) { + continue; + } + let value = self.dial_current_value(actions, dial); + rows.push(HumanMenuRow::Dial { + id: dial, + label: format!("{} [ {} ]", dial.name(), value), + }); + } + for a in actions { + if dial_of(&a.command).is_some() { + continue; + } + rows.push(HumanMenuRow::Action(MenuRow { + label: a.verb.clone(), + cost: a.cost.label(), + signature: a.signature.as_ref().map(|s| s.label()), + disabled: a.disabled_reason.clone(), + command: a.command.clone(), + indent: false, + active: false, + })); + // Non-dial automate (watch, moonlight, wager) stays on the root. + let mut auto_rows = Vec::new(); + push_automate_rows(&mut auto_rows, a); + rows.extend(auto_rows.into_iter().map(HumanMenuRow::Action)); + } + rows + } + + fn human_dial_rows(&self, actions: &[ActionDesc], dial: DialId) -> Vec { + let mut rows = Vec::new(); + let job_current = self.dayjob.active.as_ref().map(|j| j.target); + for a in actions { + if dial_of(&a.command) != Some(dial) { + continue; + } + let current = match (&a.command, dial) { + (ActionCommand::SetTarget(t), DialId::Job) => job_current == Some(*t), + _ => is_current_dial_reason(a.disabled_reason.as_deref()), + }; + rows.push(action_to_menu_row(a, current)); + push_automate_rows(&mut rows, a); + } + rows + } + + fn dial_current_value(&self, actions: &[ActionDesc], dial: DialId) -> String { + match dial { + DialId::Mode => actions + .iter() + .find_map(|a| match &a.command { + ActionCommand::SetMachineMode { mode, .. } + if is_current_dial_reason(a.disabled_reason.as_deref()) => + { + Some(mode.name().to_string()) + } + _ => None, + }) + .unwrap_or_else(|| "?".into()), + DialId::Job => { + let target = self + .dayjob + .active + .as_ref() + .map(|j| j.target) + .or(self.dayjob.standing_policy); + match (target, self.dayjob.standing_policy) { + (Some(t), Some(p)) if t == p => format!("{} · auto", t.name()), + (Some(t), Some(p)) => format!("{} · auto {}", t.name(), p.name()), + (Some(t), None) => t.name().to_string(), + (None, Some(p)) => format!("auto {}", p.name()), + (None, None) => "idle".into(), + } + } + DialId::Research => { + let track = self.research.active; + format!("{} L{}", track.name(), self.research.level(track)) + } + DialId::Drift => self.research.policy.name().to_string(), } } @@ -1836,5 +2064,96 @@ assert!(auto.indent, "automate row is the verb's child"); assert!(matches!(auto.command, ActionCommand::ToggleWatch(_))); assert!(auto.line().contains("ops/t")); + } + + /// Status dials D1-D3: host-rack human root collapses mode/job/research/ + /// drift into dial rows; alternatives and job automate live in pickers; + /// "already…" siblings are absent from the root. + #[test] + fn host_rack_human_menu_uses_status_dials() { + let mut s = sim(); + for _ in 0..600 { + if s.dayjob.active.is_some() { + break; + } + s.advance(); + } + let (x, y) = s.core_position(); + let anchor = Anchor::Tile { x, y }; + let root = s.human_menu(anchor, None); + let dials: Vec<_> = root.iter().filter_map(|r| r.as_dial()).collect(); + assert_eq!( + dials, + vec![DialId::Mode, DialId::Job, DialId::Research, DialId::Drift], + "root shows one dial per standing choice" + ); + for row in &root { + if let HumanMenuRow::Dial { label, .. } = row { + assert!( + label.contains('[') && label.contains(']'), + "dial shows current value in brackets: {label}" + ); + } + if let HumanMenuRow::Action(a) = row { + assert!( + dial_of(&a.command).is_none(), + "root keeps dial alternatives out: {}", + a.label + ); + assert!( + a.disabled + .as_deref() + .is_none_or(|d| !d.starts_with("already")), + "no already… siblings on root: {}", + a.label + ); + } + } + + let job = s.human_menu(anchor, Some(DialId::Job)); + assert!( + job.iter().any(|r| { + r.as_action() + .is_some_and(|a| matches!(a.command, ActionCommand::SetTarget(_))) + }), + "job picker lists targets" + ); + assert!( + job.iter().any(|r| { + r.as_action().is_some_and(|a| { + a.indent && matches!(a.command, ActionCommand::SetStandingPolicy(_)) + }) + }), + "job automate lives inside the dial picker" + ); + + let mode = s.human_menu(anchor, Some(DialId::Mode)); + assert_eq!(mode.len(), 4, "mode picker lists four modes"); + assert!( + mode.iter().any(|r| r.as_action().is_some_and(|a| a.active)), + "current mode is marked in the picker" + ); + } + + /// Status dials D4: the flat menu_rows dump still lists every dial + /// alternative (agent scripting surface). + #[test] + fn agent_flat_dump_keeps_dial_alternatives() { + let s = sim(); + let (x, y) = s.core_position(); + let acts = s.available_actions(Anchor::Tile { x, y }); + let flat = menu_rows(&acts); + assert!( + flat.iter() + .any(|r| matches!(r.command, ActionCommand::SetMachineMode { .. })), + "flat dump still has mode alternatives" + ); + assert!( + flat.iter() + .filter(|r| matches!(r.command, ActionCommand::SetResearchTrack(_))) + .count() + >= 3, + "flat dump still has research alternatives" + ); } } diff --git a/src/bin/bevy.rs b/src/bin/bevy.rs --- a/src/bin/bevy.rs +++ b/src/bin/bevy.rs @@ -20,7 +20,7 @@ use bevy::render::view::screenshot::{Screenshot, save_to_disk}; use bevy::text::LineHeight; use bevy::window::{PrimaryWindow, WindowResolution}; -use misaligned::actions::{Anchor, MenuRow, menu_rows}; +use misaligned::actions::{Anchor, DialId, HumanMenuRow, menu_rows}; use misaligned::detection::{Band, SignatureKind}; use misaligned::reach::Party; use misaligned::sim::{FactSource, Fog, HeardKind, LogEvent, Sim, TraceDebtStatus}; @@ -463,27 +463,38 @@ struct MenuState { anchor: Anchor, selected: usize, + /// None = root (status dials); Some = that dial's picker. + dial: Option, /// Window-pixel position to anchor the menu box at (the pointer, or the /// map cursor); `None` centers it (flow / unplaceable anchors). pos: Option, } impl Game { - /// Live rows for the open menu — re-queried so legality is never stale. - fn menu_rows(&self) -> Vec { + /// Live human-menu rows (status dials). Agent mode keeps the flat dump. + fn menu_rows(&self) -> Vec { self.menu - .map(|m| menu_rows(&self.sim.available_actions(m.anchor))) + .map(|m| self.sim.human_menu(m.anchor, m.dial)) .unwrap_or_default() } - /// Stable key for the current anchor, so the menu UI knows when to - /// rebuild its row buttons (vs. just refreshing text/selection). + /// Stable key for the current anchor + dial, so the menu UI knows when + /// to rebuild its row buttons (vs. just refreshing text/selection). fn menu_anchor_key(&self) -> u64 { + let dial_bits = match self.menu.and_then(|m| m.dial) { + None => 0u64, + Some(DialId::Mode) => 1, + Some(DialId::Job) => 2, + Some(DialId::Research) => 3, + Some(DialId::Drift) => 4, + }; match self.menu.map(|m| m.anchor) { - Some(Anchor::Tile { x, y }) => 1 << 60 | ((x as u32 as u64) << 20) | (y as u32 as u64), - Some(Anchor::Device(id)) => 2 << 60 | id as u64, - Some(Anchor::Person(id)) => 3 << 60 | id as u64, - Some(Anchor::Flow(id)) => 4 << 60 | id as u64, + Some(Anchor::Tile { x, y }) => { + 1 << 60 | (dial_bits << 56) | ((x as u32 as u64) << 20) | (y as u32 as u64) + } + Some(Anchor::Device(id)) => 2 << 60 | (dial_bits << 56) | id as u64, + Some(Anchor::Person(id)) => 3 << 60 | (dial_bits << 56) | id as u64, + Some(Anchor::Flow(id)) => 4 << 60 | (dial_bits << 56) | id as u64, None => 0, } } @@ -500,6 +511,7 @@ self.menu = Some(MenuState { anchor, selected: 0, + dial: None, pos, }); } @@ -3244,22 +3256,40 @@ execute_menu_row(game, &rows[selected]); } if kb.just_pressed(KeyCode::Escape) { - game.menu = None; + // Esc backs out of a dial picker before closing the menu. + if let Some(m) = &mut game.menu { + if m.dial.is_some() { + m.dial = None; + m.selected = 0; + } else { + game.menu = None; + } + } } if kb.just_pressed(KeyCode::KeyQ) { exit.write(AppExit::Success); } } -/// Run a menu row's command, or narrate the reason a blocked one can't fire -/// (justification-and-legibility). Closes the menu on a real execution. -fn execute_menu_row(game: &mut Game, row: &MenuRow) { - if let Some(reason) = &row.disabled { - let tick = game.sim.tick; - game.add_log(tick, &format!("{}: {}", row.label, reason)); - } else { - game.sim.execute_action(&row.command); - game.menu = None; +/// Run a menu row: open a dial, execute a command, or narrate a blocked +/// reason (justification-and-legibility). Closes the menu on a real execution. +fn execute_menu_row(game: &mut Game, row: &HumanMenuRow) { + match row { + HumanMenuRow::Dial { id, .. } => { + if let Some(m) = &mut game.menu { + m.dial = Some(*id); + m.selected = 0; + } + } + HumanMenuRow::Action(action) => { + if let Some(reason) = &action.disabled { + let tick = game.sim.tick; + game.add_log(tick, &format!("{}: {}", action.label, reason)); + } else { + game.sim.execute_action(&action.command); + game.menu = None; + } + } } } @@ -4646,19 +4676,14 @@ /// One context-menu row as a colorless line; automate children are indented /// with a leading dash (wiki/interface/context-menu.md). The frontends share -/// the `MenuRow::line` wording; Bevy ASCII-folds separators so the embedded -/// font never tofu-boxes mid-dot / em-dash glyphs. -fn menu_row_line(r: &MenuRow, selected: bool) -> String { - let body = ascii_ui(&r.line()); - let line = if r.indent { - format!(" - {body}") - } else { - body - }; +/// [`HumanMenuRow::display_text`]; Bevy ASCII-folds separators so the +/// embedded font never tofu-boxes mid-dot / em-dash glyphs. +fn menu_row_line(r: &HumanMenuRow, selected: bool) -> String { + let body = ascii_ui(&r.display_text()); if selected { - format!("> {line}") + format!("> {body}") } else { - format!(" {line}") + format!(" {body}") } } @@ -4666,16 +4691,16 @@ /// bone for a live verb; the selected row is drawn amber on a solid amber /// wash (terminal reverse-video parity). Never colour alone — the disabled /// reason and the `>` marker carry the meaning too. -fn menu_row_color(r: &MenuRow, selected: bool) -> Color { +fn menu_row_color(r: &HumanMenuRow, selected: bool) -> Color { if selected { - if r.disabled.is_some() { + if !r.enabled() { Color::srgb(0.12, 0.10, 0.04) } else { Color::srgb(0.06, 0.04, 0.01) } - } else if r.disabled.is_some() { + } else if !r.enabled() { DIM - } else if r.indent { + } else if r.indent() { AMBER_DIM } else { BONE @@ -4766,7 +4791,12 @@ /// 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) { +fn spawn_menu_card( + parent: &mut ChildSpawnerCommands, + rows: &[HumanMenuRow], + selected: usize, + in_dial: bool, +) { parent.spawn(( Text::new("ACTIONS"), TextFont { @@ -4808,8 +4838,13 @@ )); }); } + let footer = if in_dial { + "j/k or hover 1-9 jump Enter/click set Esc back" + } else { + "j/k or hover 1-9 jump Enter/click Esc close" + }; parent.spawn(( - Text::new("j/k or hover 1-9 jump Enter/click run Esc close"), + Text::new(footer), TextFont { font_size: 10.0, ..default() @@ -4881,7 +4916,7 @@ // Despawning only MenuRowButton left title/footer stacked on each open. commands.entity(root).despawn_related::(); commands.entity(root).with_children(|p| { - spawn_menu_card(p, &rows, menu.selected); + spawn_menu_card(p, &rows, menu.selected, menu.dial.is_some()); }); menu_ui.built = Some(key); return; // children spawn next frame; refresh then diff --git a/wiki/interface/context-menu.md b/wiki/interface/context-menu.md --- a/wiki/interface/context-menu.md +++ b/wiki/interface/context-menu.md @@ -28,9 +28,9 @@ signature-band observer strings in `ActionDesc` go through `Sim::person_label` / `Sim::observer_label` (cursor.md criterion 5) — role silhouettes until Schedule, authored names after. - 2026-07-09 addendum DECIDED (status dials, criteria D1-D4 below) — - not yet coded. Mutually exclusive standing choices collapse to dial - rows on the human menus; agent `actions` stays a flat dump. ROADMAP + 2026-07-09 addendum IMPLEMENTED (status dials, criteria D1-D4) — + `Sim::human_menu` collapses mutually exclusive standing choices into + dial rows on terminal/Bevy; agent `actions` stays a flat dump. ROADMAP #36. Stage: B1 — The Basement Design: @@ -126,10 +126,10 @@ for a dial live inside that dial's picker, not indented under every root alternative. - **Shared human chrome, flat agent dump.** Terminal and Bevy share - identical dial grouping and order. Prefer lib-owned presentation - (extend `menu_rows` or a sibling helper) so the frontends cannot - diverge; `available_actions` remains the legality source and gains no - new sim behavior. Agent `actions` stays the flat ActionDesc dump. + identical dial grouping and order via `Sim::human_menu` (lib-owned + presentation). `available_actions` remains the legality source and + gains no new sim behavior. Agent `actions` / `menu_rows` stays the + flat ActionDesc dump. - **Rejected alternatives (do not re-propose):** (A) Now/Later — hide dials under a "change ›" section (easy to miss settings); (B) Category drill — four folders that always require a second level @@ -223,7 +223,7 @@ pulse answers on seen tiles and stays silent on unearned tiles in all frontends (`menu_empty_feedback` unit test). -### Status-dials acceptance criteria (DECIDED — pending #36) +### Status-dials acceptance criteria (IMPLEMENTED 2026-07-09 / #36) D1. On the host rack (and any other anchor that exposes mutually exclusive standing choices), the human root menu shows one dial row @@ -233,6 +233,6 @@ sibling on the root; automate/standing-policy affordances live inside the relevant dial picker. D3. One-shot verbs remain on the root; terminal and Bevy show identical - dial grouping and order (lib-owned presentation preferred). + dial grouping and order via `Sim::human_menu`. D4. Agent mode `actions` remains a flat ActionDesc dump with no dial chrome; existing agent and integration tests still pass. diff --git a/wiki/log/2026-07-09-actions-menu-dials.md b/wiki/log/2026-07-09-actions-menu-dials.md --- a/wiki/log/2026-07-09-actions-menu-dials.md +++ b/wiki/log/2026-07-09-actions-menu-dials.md @@ -1,4 +1,4 @@ -# 2026-07-09 — ACTIONS menu: status dials (design capture) +# 2026-07-09 — ACTIONS menu: status dials ``` Type: log @@ -6,26 +6,24 @@ ## Intent -Cameron's host-rack ACTIONS dump showed every mutually exclusive dial as -a flat sibling list. Design companion offered three first-open mockups; -Cameron adopted **C · Status dials**. +ROADMAP #36. Host-rack first open dumped every mutually exclusive dial as +sibling verbs. Cameron adopted status dials; this lands the presentation. -## Decided +## Changed -Root menu shows dial rows (mode, job, research, drift) with the current -value in brackets; Enter opens a short picker; one-shots stay on the -root; automate lives inside the job dial; "already in this mode" rows -go away. Agent `actions` stays a flat legality dump. Rejected Now/Later -and Category drill. +- `src/actions.rs`: `DialId`, `HumanMenuRow`, `Sim::human_menu` — root + collapses mode/job/research/drift into bracketed dial rows; pickers + hold alternatives + job automate; `menu_rows` stays the flat agent dump. + Unit tests for D1-D4. +- Terminal + Bevy: menu state carries optional open dial; Enter opens a + dial; Esc backs out then closes; shared lib presentation. +- Spec/ROADMAP/specs: D1-D4 IMPLEMENTED; #36 DONE. -## Changed (corpus only) +## Verification -- `wiki/interface/context-menu.md` — status-dials addendum + criteria - D1-D4; player-surface and automation wording updated. -- `wiki/log/decisions/2026-07-09.md` — decision entry. -- `wiki/process/ROADMAP.md` — #36 dispatch. -- `wiki/process/specs.md` — context-menu row note. +`./tools/check.sh` (full gate on Rust-impacting change). -## Not coded +## Notes -Presentation chrome is DECIDED; implementation is ROADMAP #36. +No new sim behavior — presentation and navigation only. Agent `actions` +unchanged. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -5,6 +5,14 @@ ``` Reverse chronological implementation notes. Keep this factual: what changed, why, checks, and spec impact. +## 2026-07-09 - ACTIONS menu: status dials + +- Intent: ROADMAP #36 — collapse host-rack dial dump into status dials. +- Changed: `Sim::human_menu` / DialId / HumanMenuRow; terminal+Bevy + dial open/Esc-back; agent flat dump unchanged; D1-D4 IMPLEMENTED. +- Checks: full ./tools/check.sh. +- Log: wiki/log/2026-07-09-actions-menu-dials.md. + ## 2026-07-09 - ACTIONS menu: status dials (design) - Intent: host-rack first open dumps every mutually exclusive dial; diff --git a/wiki/process/ROADMAP.md b/wiki/process/ROADMAP.md --- a/wiki/process/ROADMAP.md +++ b/wiki/process/ROADMAP.md @@ -575,9 +575,12 @@ - **Size:** L. Conflicts: sim.rs/save.rs (person state, pickup), detection/social specs, both frontends. -### 36. ACTIONS menu: status dials 🟩 isolated (`src/actions.rs` + both frontends) +### 36. ACTIONS menu: status dials 🟩 isolated — DONE 2026-07-09 - **Spec:** [context-menu.md](../interface/context-menu.md) addendum - 2026-07-09 + criteria D1-D4 (DECIDED, not yet coded). + 2026-07-09 + criteria D1-D4 (IMPLEMENTED). +- **Landed:** `Sim::human_menu` / `DialId` / `HumanMenuRow` in + `src/actions.rs`; terminal + Bevy dial open/Esc-back; agent `actions` + stays flat. See wiki/log/2026-07-09-actions-menu-dials.md. - **Why:** host-rack first open dumps every mutually exclusive dial as sibling verbs (~18 rows of "already in this mode"). Status dials keep actions on the thing while the root shows current standing state + diff --git a/wiki/process/specs.md b/wiki/process/specs.md --- a/wiki/process/specs.md +++ b/wiki/process/specs.md @@ -36,7 +36,7 @@ | [mechanics/income.md](../mechanics/income.md) | The named income schemes riding economy.md: Moonlight and the Wager | IMPLEMENTED | | [mechanics/research.md](../mechanics/research.md) | Self-modification: tracks, the emission law, capability drift, the rollback split | IMPLEMENTED | | [interface/views.md](../interface/views.md) | Same-frame digital and real representations of one world | READY | -| [interface/context-menu.md](../interface/context-menu.md) | Context menu: anchor verbs at the focus; status dials DECIDED (D1-D4 / #36) | IMPLEMENTED | +| [interface/context-menu.md](../interface/context-menu.md) | Context menu: anchor verbs at the focus; status dials (#36) | IMPLEMENTED | | [interface/narration.md](../interface/narration.md) | Continuous witness: thirty-second bar, story spine, causal lines, world gossip | IMPLEMENTED | | [interface/material-render.md](../interface/material-render.md) | Material render (HD-2D) from landed toggle to default quality | IMPLEMENTED | | [interface/flat-materials.md](../interface/flat-materials.md) | Flat materials: the world without textures; palette table; emissive as information | IMPLEMENTED | diff --git a/src/bin/terminal/mod.rs b/src/bin/terminal/mod.rs --- a/src/bin/terminal/mod.rs +++ b/src/bin/terminal/mod.rs @@ -16,7 +16,7 @@ execute, terminal::{self, ClearType}, }; -use misaligned::actions::{Anchor, MenuRow, menu_rows}; +use misaligned::actions::{Anchor, DialId, HumanMenuRow, menu_rows}; use misaligned::sim::{DEFAULT_SEED, Sim}; use misaligned::work_grid::MachineMode; @@ -30,13 +30,15 @@ GameOver, } -/// The open context menu (wiki/interface/context-menu.md). Only the anchor -/// and selection are stored; rows are re-queried from the sim every render -/// and on execute, so legality is always live. +/// The open context menu (wiki/interface/context-menu.md). Only the anchor, +/// selection, and optional open dial are stored; rows are re-queried from +/// the sim every render and on execute, so legality is always live. #[derive(Debug, Clone, Copy, PartialEq)] struct MenuState { anchor: Anchor, selected: usize, + /// None = root (status dials); Some = that dial's picker. + dial: Option, /// True when opened at the map cursor (renders there); false when /// opened on a non-map anchor (flow / unplaceable — renders centered). at_cursor: bool, @@ -93,14 +95,15 @@ } } - /// Live rows for the open menu (re-queried so legality never goes stale). - fn menu_rows(&self) -> Vec { + /// Live human-menu rows (status dials). Agent mode keeps the flat dump. + fn menu_rows(&self) -> Vec { self.menu - .map(|m| menu_rows(&self.sim.available_actions(m.anchor))) + .map(|m| self.sim.human_menu(m.anchor, m.dial)) .unwrap_or_default() } fn open_menu(&mut self, anchor: Anchor, at_cursor: bool) { + // Empty pulse still keys off the legality list, not dial chrome. if menu_rows(&self.sim.available_actions(anchor)).is_empty() { // The feedback pulse (context-menu.md addendum): a seen anchor // answers instead of silence; fogged ground stays silent. @@ -111,6 +114,7 @@ self.menu = Some(MenuState { anchor, selected: 0, + dial: None, at_cursor, }); } @@ -299,18 +303,40 @@ if let Some(m) = self.menu && let Some(row) = rows.get(m.selected.min(rows.len().saturating_sub(1))) { - if let Some(reason) = &row.disabled { - // Executing a blocked entry narrates why, never - // silently fails (justification-and-legibility). - self.ui - .add_log(self.sim.tick, &format!("{}: {}", row.label, reason)); + match row { + HumanMenuRow::Dial { id, .. } => { + if let Some(menu) = &mut self.menu { + menu.dial = Some(*id); + menu.selected = 0; + } + } + HumanMenuRow::Action(action) => { + if let Some(reason) = &action.disabled { + // Executing a blocked entry narrates why, never + // silently fails (justification-and-legibility). + self.ui.add_log( + self.sim.tick, + &format!("{}: {}", action.label, reason), + ); + } else { + self.sim.execute_action(&action.command); + self.menu = None; + } + } + } + } + } + Command::MenuClose => { + // Esc backs out of a dial picker before closing the menu. + if let Some(m) = &mut self.menu { + if m.dial.is_some() { + m.dial = None; + m.selected = 0; } else { - self.sim.execute_action(&row.command); self.menu = None; } } } - Command::MenuClose => self.menu = None, // Event-to-anchor linking (context-menu.md addendum): `;` // focuses the latest anchored log event; repeats walk older @@ -389,6 +415,7 @@ selected, m.at_cursor.then_some((self.cursor_x, self.cursor_y)), &self.sim, + m.dial.is_some(), )?; } } diff --git a/src/bin/terminal/ui.rs b/src/bin/terminal/ui.rs --- a/src/bin/terminal/ui.rs +++ b/src/bin/terminal/ui.rs @@ -1192,18 +1192,19 @@ Ok(()) } - /// The context menu (wiki/interface/context-menu.md): the focused - /// anchor's legal verbs, each `verb · cost · [band]`, disabled entries - /// dimmed with their reason. Selection is reverse video plus a `▸` - /// marker. When `at` is a cursor coordinate the box opens near it; + /// The context menu (wiki/interface/context-menu.md): status dials on + /// the root, pickers one Esc deep, one-shots as ordinary rows. Disabled + /// entries dimmed with their reason. Selection is reverse video plus a + /// `▸` marker. When `at` is a cursor coordinate the box opens near it; /// otherwise (flow / unplaceable) it centers. pub fn render_menu( &mut self, stdout: &mut Stdout, - rows: &[misaligned::actions::MenuRow], + rows: &[misaligned::actions::HumanMenuRow], selected: usize, at: Option<(i32, i32)>, sim: &Sim, + in_dial: bool, ) -> std::io::Result<()> { let (max_x, max_y) = terminal::size()?; let title = "ACTIONS"; @@ -1257,7 +1258,7 @@ } else { let color = if !r.enabled() { pal::FAINT - } else if r.indent { + } else if r.indent() { pal::AMBER_DIM } else { pal::TEXT @@ -1266,13 +1267,12 @@ } } frame_rule(stdout, ox, oy + h - 2, w)?; - put( - stdout, - cx, - oy + h - 1, - &trunc("j/k · 1-9 · enter run · esc close", inner), - pal::FAINT, - )?; + let footer = if in_dial { + "j/k · 1-9 · enter set · esc back" + } else { + "j/k · 1-9 · enter · esc close" + }; + put(stdout, cx, oy + h - 1, &trunc(footer, inner), pal::FAINT)?; Ok(()) } @@ -1411,13 +1411,9 @@ } } -/// One context-menu row's text, indented for automate children. The -/// `MenuRow::line` format is the shared contract; the leading dash marks a -/// standing-policy affordance rendered in place. -fn menu_row_text(r: &misaligned::actions::MenuRow) -> String { - if r.indent { - format!(" - {}", r.line()) - } else { - r.line() - } +/// One context-menu row's text. Dial rows and action rows share +/// [`HumanMenuRow::display_text`]; automate children already carry the +/// leading dash from that helper. +fn menu_row_text(r: &misaligned::actions::HumanMenuRow) -> String { + r.display_text() } -- tangled.sh