diff --git a/src/actions.rs b/src/actions.rs index ca9a028..d212b36 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -271,6 +271,17 @@ impl DialId { } } + /// Menu title when this dial's picker is open — the repeated verb + /// prefix lifted out of every option row. + pub fn picker_title(self) -> &'static str { + match self { + DialId::Mode => "delegate machine", + DialId::Job => "work the job", + DialId::Research => "research", + DialId::Drift => "drift policy", + } + } + /// Pad width for the dial-name column so bracketed values line up /// (monospace ACTIONS chrome). Longest name is `research`. pub const NAME_WIDTH: usize = 8; @@ -374,18 +385,41 @@ pub fn menu_rows(actions: &[ActionDesc]) -> Vec { rows } -fn action_to_menu_row(a: &ActionDesc, current: bool) -> MenuRow { - let mut label = a.verb.clone(); +/// Short option label inside a dial picker — the dial title already names +/// the verb, so rows are just the choice (plus progress for research). +fn dial_picker_label(sim: &Sim, a: &ActionDesc, dial: DialId, current: bool) -> String { + let base = match (&a.command, dial) { + (ActionCommand::SetMachineMode { mode, .. }, DialId::Mode) => mode.name().to_string(), + (ActionCommand::SetTarget(t), DialId::Job) => t.name().to_string(), + (ActionCommand::SetResearchTrack(track), DialId::Research) => { + let cost = sim.research.next_cost(*track); + let progress = sim.research.progress_toward(*track); + format!( + "{} L{} · {:.0}/{:.0}", + track.name(), + sim.research.level(*track), + progress, + cost + ) + } + (ActionCommand::SetMaskingPolicy(policy), DialId::Drift) => policy.name().to_string(), + _ => a.verb.clone(), + }; + if current { + format!("{base} · current") + } else { + base + } +} + +fn dial_picker_row(sim: &Sim, a: &ActionDesc, dial: DialId, current: bool) -> MenuRow { let disabled = if current { - if !label.contains("current") { - label.push_str(" · current"); - } None } else { a.disabled_reason.clone() }; MenuRow { - label, + label: dial_picker_label(sim, a, dial, current), cost: a.cost.label(), signature: a.signature.as_ref().map(|s| s.label()), disabled, @@ -409,6 +443,26 @@ fn push_automate_rows(rows: &mut Vec, a: &ActionDesc) { } } +/// Automate rows inside a dial picker: drop the dial's repeated noun +/// (`standing policy: Meet` -> `auto: Meet`). +fn push_dial_automate_rows(rows: &mut Vec, a: &ActionDesc, dial: DialId) { + if let Some(auto) = &a.automate { + let short = match (&auto.command, dial) { + (ActionCommand::SetStandingPolicy(t), DialId::Job) => t.name().to_string(), + _ => auto.verb.clone(), + }; + rows.push(MenuRow { + label: format!("auto: {short}"), + 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 @@ -487,8 +541,8 @@ impl Sim { (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.push(dial_picker_row(self, a, dial, current)); + push_dial_automate_rows(&mut rows, a, dial); } rows } @@ -2159,6 +2213,21 @@ mod tests { mode.iter().any(|r| r.as_action().is_some_and(|a| a.active)), "current mode is marked in the picker" ); + assert!( + mode.iter().all(|r| { + r.as_action() + .is_some_and(|a| !a.label.contains("delegate machine")) + }), + "mode picker drops the repeated verb prefix (title carries it)" + ); + assert_eq!(DialId::Mode.picker_title(), "delegate machine"); + assert!( + mode.iter().any(|r| { + r.as_action() + .is_some_and(|a| a.label.starts_with("day-job")) + }), + "mode options are bare mode names" + ); } /// Status dials D4: the flat menu_rows dump still lists every dial diff --git a/src/bin/bevy.rs b/src/bin/bevy.rs index f0dbe3d..c50e1fb 100644 --- a/src/bin/bevy.rs +++ b/src/bin/bevy.rs @@ -5000,10 +5000,11 @@ fn spawn_menu_card( parent: &mut ChildSpawnerCommands, rows: &[HumanMenuRow], selected: usize, + title: &str, in_dial: bool, ) { parent.spawn(( - Text::new("ACTIONS"), + Text::new(title), TextFont { font_size: 13.0, ..default() @@ -5121,7 +5122,13 @@ fn manage_menu_ui( // 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, menu.dial.is_some()); + spawn_menu_card( + p, + &rows, + menu.selected, + menu.dial.map(|d| d.picker_title()).unwrap_or("ACTIONS"), + menu.dial.is_some(), + ); }); menu_ui.built = Some(key); return; // children spawn next frame; refresh then diff --git a/src/bin/terminal/mod.rs b/src/bin/terminal/mod.rs index b23deb5..d05e484 100644 --- a/src/bin/terminal/mod.rs +++ b/src/bin/terminal/mod.rs @@ -409,12 +409,14 @@ impl App { self.menu = None; } else { let selected = m.selected.min(rows.len() - 1); + let title = m.dial.map(|d| d.picker_title()).unwrap_or("ACTIONS"); self.ui.render_menu( stdout, &rows, selected, m.at_cursor.then_some((self.cursor_x, self.cursor_y)), &self.sim, + title, m.dial.is_some(), )?; } diff --git a/src/bin/terminal/ui.rs b/src/bin/terminal/ui.rs index e45e56e..e2b1678 100644 --- a/src/bin/terminal/ui.rs +++ b/src/bin/terminal/ui.rs @@ -1204,10 +1204,10 @@ impl UI { selected: usize, at: Option<(i32, i32)>, sim: &Sim, + title: &str, in_dial: bool, ) -> std::io::Result<()> { let (max_x, max_y) = terminal::size()?; - let title = "ACTIONS"; // Width fits the longest row; height fits every row plus chrome. let widest = rows .iter() diff --git a/wiki/interface/context-menu.md b/wiki/interface/context-menu.md index 2b70e0f..c802ffb 100644 --- a/wiki/interface/context-menu.md +++ b/wiki/interface/context-menu.md @@ -116,7 +116,10 @@ noise). Cameron adopted **status dials** (2026-07-09): the root answers monospace columns — dial name left-padded to a fixed width, then the **current value in brackets** — so the `[`s line up: `mode [ day-job ]`, `job [ Meet · auto ]`. Enter opens a - short picker of alternatives; Esc returns to the root. + short picker of alternatives; Esc returns to the root. Inside a + picker the menu title becomes the dial's verb (`delegate machine`, + `work the job`, …) and each option is just the choice — no repeated + prefix on every row. - **Current value is status.** Do not also list the current choice as a disabled "already in this mode" / "already the active…" sibling. The bracketed value is the status.