diff --git a/crates/misaligned-bevy/src/main.rs b/crates/misaligned-bevy/src/main.rs index dcbe173..fd639dc 100644 --- a/crates/misaligned-bevy/src/main.rs +++ b/crates/misaligned-bevy/src/main.rs @@ -48,10 +48,11 @@ const COMPUTE_CHANNELS: usize = 5; /// Compact context-menu card width; human rows carry choices, not descriptor /// receipts (wiki/interface/context-menu.md). const MENU_WIDTH: f32 = 300.0; -/// The machine grammar is one clean line, not a panel. This width leaves the -/// three words readable without letting the affordance become a HUD card -/// (wiki/interface/context-menu.md, frequent machine grammar). -const HOVER_VERB_BAR_WIDTH: f32 = 248.0; +/// The hover grammar grows only when several actionable bodies share a tile. +/// Width follows the union rather than reserving an empty HUD-sized strip. +const MACHINE_VERB_BAR_WIDTH: f32 = 248.0; +const DEVICE_VERB_BAR_WIDTH: f32 = 152.0; +const UNION_VERB_BAR_WIDTH: f32 = 372.0; /// Air between the fixed mode grammar and the machine base it belongs to. const HOVER_VERB_BAR_GAP: f32 = 12.0; const DETECTION_ROWS: usize = 6; @@ -1107,9 +1108,23 @@ struct HoverVerbWord { index: usize, } -enum HoverVerbTarget { - Machine { id: u32, x: i32, y: i32 }, - Device { id: u32, x: i32, y: i32 }, +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct HoverVerbTarget { + machine: Option, + device: Option, + x: i32, + y: i32, +} + +impl HoverVerbTarget { + fn width(self) -> f32 { + match (self.machine.is_some(), self.device.is_some()) { + (true, true) => UNION_VERB_BAR_WIDTH, + (true, false) => MACHINE_VERB_BAR_WIDTH, + (false, true) => DEVICE_VERB_BAR_WIDTH, + (false, false) => 0.0, + } + } } /// The context-menu root node (wiki/interface/context-menu.md), absolute and /// hidden until an anchor is focused. Its children are the whole card @@ -5596,7 +5611,7 @@ fn setup_ui(mut commands: Commands) { position_type: PositionType::Absolute, left: Val::Px(0.0), top: Val::Px(0.0), - width: Val::Px(HOVER_VERB_BAR_WIDTH), + width: Val::Px(MACHINE_VERB_BAR_WIDTH), height: Val::Px(32.0), flex_direction: FlexDirection::Row, align_items: AlignItems::Center, @@ -5610,7 +5625,10 @@ fn setup_ui(mut commands: Commands) { HoverVerbBar, )) .with_children(|line| { - for (index, label) in ["WORK", "/", "THINK", "/", "LIE"].into_iter().enumerate() { + for (index, label) in ["WORK", "/", "THINK", "/", "LIE", "TAP", "/", "TAKE"] + .into_iter() + .enumerate() + { line.spawn(( Text::new(label), TextFont { @@ -5619,6 +5637,14 @@ fn setup_ui(mut commands: Commands) { }, TextColor(DIM), Visibility::Visible, + Node { + margin: if index == 5 { + UiRect::left(Val::Px(18.0)) + } else { + UiRect::ZERO + }, + ..default() + }, HoverVerbWord { index }, )); } @@ -5651,16 +5677,16 @@ fn setup_ui(mut commands: Commands) { /// right only when the machine is too close to the canvas edge. This keeps the /// text off the chassis while making its attachment independent of where the /// pointer happens to sit inside the rack's tile. -fn hover_verb_bar_position(machine_base: Vec2, window_size: Vec2) -> Vec2 { - let world_width = (window_size.x - SIDEBAR_WIDTH).max(HOVER_VERB_BAR_WIDTH + 24.0); - let left_of_machine = machine_base.x - HOVER_VERB_BAR_WIDTH - HOVER_VERB_BAR_GAP; +fn hover_verb_bar_position(machine_base: Vec2, window_size: Vec2, bar_width: f32) -> Vec2 { + let world_width = (window_size.x - SIDEBAR_WIDTH).max(bar_width + 24.0); + let left_of_machine = machine_base.x - bar_width - HOVER_VERB_BAR_GAP; let unclamped_left = if left_of_machine >= 12.0 { left_of_machine } else { machine_base.x + HOVER_VERB_BAR_GAP }; Vec2::new( - unclamped_left.clamp(12.0, world_width - HOVER_VERB_BAR_WIDTH - 12.0), + unclamped_left.clamp(12.0, world_width - bar_width - 12.0), (machine_base.y - 58.0).clamp(12.0, window_size.y - 44.0), ) } @@ -5688,20 +5714,31 @@ fn hover_verb_bar_anchor( } } -/// Resolve the same frequent-machine target used by the 1/2/3 hotkeys. -/// Pointer hover may retarget the affordance, but moving the pointer away -/// falls back to machine selection and then the attention reticule. +/// Resolve one physical tile, then union every stable verb family living on +/// it. A co-located device must not replace its machine (or vice versa). +/// One-off verbs already remain in the tile's full context menu. fn hover_verb_bar_target(game: &Game, pointer: Option<(i32, i32)>) -> Option { - let focused = pointer.unwrap_or((game.cursor_x, game.cursor_y)); - if let Some(device) = game.sim.reach.known_at(focused.0, focused.1) { - return Some(HoverVerbTarget::Device { - id: device.id, - x: device.x, - y: device.y, - }); + let at = |x, y| { + let machine = game.machine_at(x, y).map(|(id, _, _)| id); + let device = game.sim.reach.known_at(x, y).map(|device| device.id); + (machine.is_some() || device.is_some()).then_some(HoverVerbTarget { + machine, + device, + x, + y, + }) + }; + if let Some((x, y)) = pointer + && let Some(target) = at(x, y) + { + return Some(target); + } + if let Some(&id) = game.selected_machines.iter().next() + && let Some(machine) = game.sim.compute.machines.iter().find(|m| m.id == id) + { + return at(machine.x, machine.y); } - game.primary_machine_tile(pointer) - .map(|(id, x, y)| HoverVerbTarget::Machine { id, x, y }) + at(game.cursor_x, game.cursor_y) } /// Keep the three machine verbs physically adjacent to the active rack. @@ -5748,48 +5785,53 @@ fn render_hover_verb_bar( return; } - let (x, y) = match target { - HoverVerbTarget::Machine { x, y, .. } | HoverVerbTarget::Device { x, y, .. } => (x, y), - }; - let Some(machine_base) = hover_verb_bar_anchor(mode.material, x, y, &camera_q, &camera3_q) + let Some(machine_base) = + hover_verb_bar_anchor(mode.material, target.x, target.y, &camera_q, &camera3_q) else { *visibility = Visibility::Hidden; return; }; - let position = - hover_verb_bar_position(machine_base, Vec2::new(window.width(), window.height())); + let bar_width = target.width(); + let position = hover_verb_bar_position( + machine_base, + Vec2::new(window.width(), window.height()), + bar_width, + ); + node.width = Val::Px(bar_width); node.left = Val::Px(position.x); node.top = Val::Px(position.y); *visibility = Visibility::Visible; + let current_mode = target + .machine + .and_then(|machine| game.sim.work_grid.mode(machine)); + let device = target.device.and_then(|id| game.sim.reach.device(id)); + let tapped = device.is_some_and(|d| d.subscribed_by(Party::Player)); + let taken = device.is_some_and(|d| d.controller == Party::Player); + let slots = [ + ( + "WORK", + current_mode == Some(MachineMode::Work), + target.machine.is_some(), + ), + ("/", false, target.machine.is_some()), + ( + "THINK", + current_mode == Some(MachineMode::Think), + target.machine.is_some(), + ), + ("/", false, target.machine.is_some()), + ( + "LIE", + current_mode == Some(MachineMode::Lie), + target.machine.is_some(), + ), + ("TAP", tapped, target.device.is_some()), + ("/", false, target.device.is_some()), + ("TAKE", taken, target.device.is_some()), + ]; for (word, mut text, mut color, mut word_visibility) in words.iter_mut() { - let (label, active, shown) = match target { - HoverVerbTarget::Machine { id, .. } => { - let mode = game.sim.work_grid.mode(id); - let slot = [ - ("WORK", mode == Some(MachineMode::Work)), - ("/", false), - ("THINK", mode == Some(MachineMode::Think)), - ("/", false), - ("LIE", mode == Some(MachineMode::Lie)), - ][word.index]; - (slot.0, slot.1, true) - } - HoverVerbTarget::Device { id, .. } => { - let device = game.sim.reach.device(id); - let tapped = device.is_some_and(|d| d.subscribed_by(Party::Player)); - let taken = device.is_some_and(|d| d.controller == Party::Player); - let slots = [ - ("TAP", tapped), - ("/", false), - ("TAKE", taken), - ("", false), - ("", false), - ]; - let slot = slots[word.index]; - (slot.0, slot.1, word.index < 3) - } - }; + let (label, active, shown) = slots[word.index]; text.0 = label.into(); *word_visibility = if shown { Visibility::Visible @@ -5809,7 +5851,7 @@ fn render_hover_verb_bar( #[cfg(test)] mod hover_verb_bar_tests { use super::{ - Game, HOVER_VERB_BAR_GAP, HOVER_VERB_BAR_WIDTH, HoverVerbTarget, hover_verb_bar_position, + Game, HOVER_VERB_BAR_GAP, MACHINE_VERB_BAR_WIDTH, hover_verb_bar_position, hover_verb_bar_target, }; use bevy::prelude::Vec2; @@ -5817,11 +5859,12 @@ mod hover_verb_bar_tests { #[test] fn grammar_sits_at_a_stable_offset_from_the_machine_base() { let machine = Vec2::new(640.0, 360.0); - let position = hover_verb_bar_position(machine, Vec2::new(1280.0, 720.0)); + let position = + hover_verb_bar_position(machine, Vec2::new(1280.0, 720.0), MACHINE_VERB_BAR_WIDTH); assert_eq!( position.x, - machine.x - HOVER_VERB_BAR_WIDTH - HOVER_VERB_BAR_GAP + machine.x - MACHINE_VERB_BAR_WIDTH - HOVER_VERB_BAR_GAP ); assert_eq!(position.y, machine.y - 58.0); } @@ -5829,22 +5872,26 @@ mod hover_verb_bar_tests { #[test] fn grammar_flips_right_at_the_left_canvas_edge() { let machine = Vec2::new(40.0, 100.0); - let position = hover_verb_bar_position(machine, Vec2::new(1280.0, 720.0)); + let position = + hover_verb_bar_position(machine, Vec2::new(1280.0, 720.0), MACHINE_VERB_BAR_WIDTH); assert_eq!(position.x, machine.x + HOVER_VERB_BAR_GAP); } #[test] - fn grammar_stays_on_the_reticule_machine_without_pointer_hover() { + fn grammar_unions_every_stable_verb_family_on_the_reticule_tile() { let game = Game::new(); let core = game.sim.core_position(); assert_eq!( - hover_verb_bar_target(&game, None).map(|target| match target { - HoverVerbTarget::Machine { x, y, .. } | HoverVerbTarget::Device { x, y, .. } => - (x, y), - }), - Some(core) + hover_verb_bar_target(&game, None).map(|target| (target.x, target.y)), + Some(core), + ); + let target = hover_verb_bar_target(&game, None).expect("core tile has stable verbs"); + assert!(target.machine.is_some(), "WORK / THINK / LIE survive"); + assert!( + target.device.is_some(), + "TAP / TAKE join rather than replace them" ); } } diff --git a/wiki/interface/context-menu.md b/wiki/interface/context-menu.md index 55a8cc4..5fbbb5e 100644 --- a/wiki/interface/context-menu.md +++ b/wiki/interface/context-menu.md @@ -260,10 +260,18 @@ reuse the same bare line, attachment, typography, and active/dim treatment as `TAP / TAKE`. The bar names the stable verbs; reversible state is expressed by brightness there and by the contextual `UNTAP` row inside the full menu. -- Target order is the same as the direct mode controls: (1) an owned computer - under the pointer, (2) the primary machine selection, then (3) an owned - computer under the attention reticule. Moving the pointer away never hides - the line while selection or reticule focus still resolves to a machine. +A tile can carry several actionable bodies. Their stable state families are +unioned rather than resolved by priority: a machine sharing its tile with a +known device reads `WORK / THINK / LIE` and `TAP / TAKE` together. Neither may +replace the other. Infrequent one-off verbs—REVIEW, SALVAGE, social acts, +construction, and similar actions—remain selectable rows in the full context +menu below this frequent grammar; they do not accrete into the hover line. + +- Target order is: (1) an actionable machine/device tile under the pointer, + (2) the primary machine selection and every body sharing its tile, then (3) + the actionable machine/device tile under the attention reticule. Moving the + pointer away never hides the line while selection or reticule focus still + resolves to a stable state family. The line appears only during active play with the full context menu closed. - The computer's current mode is bone-bright. The other two words and slash separators are dim neutral chrome. Amber remains ownership/presence on the diff --git a/wiki/log/2026-07-11-union-hover-verbs.md b/wiki/log/2026-07-11-union-hover-verbs.md new file mode 100644 index 0000000..810548d --- /dev/null +++ b/wiki/log/2026-07-11-union-hover-verbs.md @@ -0,0 +1,23 @@ +# Co-located hover verbs union + +``` +Type: log +``` + +- Intent: keep a device sharing Rack 3's tile from replacing the machine's + WORK / THINK / LIE grammar. +- Changed: hover focus now resolves a physical tile and unions its machine and + device state families. Co-located bodies show WORK / THINK / LIE and TAP / + TAKE together; single-body tiles retain a compact bar. One-off actions remain + in the full tile context menu. +- Design/spec impact: amended the context-menu hover grammar to distinguish + persistent state families from one-off menu actions and forbid priority + replacement between co-located bodies. +- Checks: targeted Bevy hover-bar tests passed. The `signal` screenshot harness + captured Rack 3 reading WORK / THINK / LIE and TAP / TAKE together with WORK + and TAP bone-bright. The frontend landing gate passed, including Bevy/assets + tests, Clippy, corpus/wiki gates, and process fixtures. + +Defense: `wiki/interface/context-menu.md` requires actions to live on the thing; +unioning co-located state families preserves every actionable thing at that +physical anchor without turning infrequent actions into permanent HUD chrome. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index 0f04190..8ab6b39 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -11,6 +11,11 @@ add or amend a session log, then re-run the generator. +## 2026-07-11 - Co-located hover verbs union + +- Intent: (see session log) +- Log: [wiki/log/2026-07-11-union-hover-verbs.md](2026-07-11-union-hover-verbs.md) + ## 2026-07-11 - Tick: update stale save version v21 -> v22 - Intent: Tick audit of the machine-work / intel sinks slice found the knowledge page `sim-mechanics.md` still recording "v21 save format" while the code is at `SAVE_VERSION = 22` (v22 adds Foundation data-hall segment coordination, landed in commit ebf40ff0). CLAUDE.md and AGENTS.md gu...