diff --git a/crates/misaligned-bevy/src/main.rs b/crates/misaligned-bevy/src/main.rs index 964ca548..ef149aa9 100644 --- a/crates/misaligned-bevy/src/main.rs +++ b/crates/misaligned-bevy/src/main.rs @@ -849,11 +849,12 @@ impl Game { /// slab affordance). A view command: no sim/save mutation, no tick. fn open_operations(&mut self) { self.menu = None; - self.ops = Some( - self.ops_resume - .take() - .unwrap_or_else(OperationsWorkspace::open), - ); + let mut ops = self + .ops_resume + .take() + .unwrap_or_else(OperationsWorkspace::open); + ops.clamp_to(&self.sim); + self.ops = Some(ops); } /// Target-specific entry into the same workspace (person-detail routes @@ -961,7 +962,7 @@ impl Game { /// confirmation -> detail pane -> object list -> world. fn ops_back(&mut self) { if let Some(ops) = &mut self.ops - && ops.back() + && ops.back(&self.sim) { self.ops = None; } diff --git a/crates/misaligned-bevy/src/operations_ui.rs b/crates/misaligned-bevy/src/operations_ui.rs index 7eaee9f7..9cf1cc24 100644 --- a/crates/misaligned-bevy/src/operations_ui.rs +++ b/crates/misaligned-bevy/src/operations_ui.rs @@ -237,7 +237,7 @@ mod operations_workspace_tests { .iter() .position(|entry| entry.submenu() == Some(OpsActionSubmenu::Leverage)) .unwrap(); - ops.action = leverage; + ops.select_action_index(&sim, leverage); assert_eq!(ops.select(&sim), OpsSelect::None); let choices = ops.action_entries(&sim); assert!(!choices.is_empty()); @@ -478,8 +478,9 @@ mod operations_focus_tests { let mut game = Game::new(); game.open_operations_at(&OperationsTarget::RecordingInbox); assert_eq!(game.ops.as_ref().unwrap().view, OperationsView::Intel); - game.ops.as_mut().unwrap().pane = OpsPane::Actions; - game.ops.as_mut().unwrap().action = 1; + let ops = game.ops.as_mut().unwrap(); + ops.pane = OpsPane::Actions; + ops.select_action_index(&game.sim, 1); game.ops_focus_actuator(); assert!(game.ops.is_none(), "FOCUS returns to the world"); @@ -488,7 +489,13 @@ mod operations_focus_tests { OperationsView::Intel ); assert_eq!(game.ops_resume.as_ref().unwrap().pane, OpsPane::Actions); - assert_eq!(game.ops_resume.as_ref().unwrap().action, 1); + assert_eq!( + game.ops_resume + .as_ref() + .unwrap() + .selected_action_index(&game.sim), + Some(1) + ); assert_eq!( game.ops_resume .as_ref() @@ -509,7 +516,10 @@ mod operations_focus_tests { OperationsTarget::RecordingInbox ); assert_eq!(game.ops.as_ref().unwrap().pane, OpsPane::Actions); - assert_eq!(game.ops.as_ref().unwrap().action, 1); + assert_eq!( + game.ops.as_ref().unwrap().selected_action_index(&game.sim), + Some(1) + ); } } @@ -642,8 +652,7 @@ pub(super) fn ops_pointer( && ops.confirm.is_none() { ops.pane = OpsPane::Actions; - ops.action = row.index; - ops.clamp_to(&game.sim); + ops.select_action_index(&game.sim, row.index); } } Interaction::Pressed => { @@ -652,8 +661,7 @@ pub(super) fn ops_pointer( if let Some(ops) = &mut game.ops { ops.pane = OpsPane::Actions; ops.confirm = None; - ops.action = row.index; - ops.clamp_to(&game.sim); + ops.select_action_index(&game.sim, row.index); } } game.ops_enter(); @@ -669,8 +677,7 @@ pub(super) fn ops_pointer( && ops.confirm.is_none() { ops.pane = OpsPane::Related; - ops.related = row.index; - ops.clamp_to(&game.sim); + ops.select_related_index(&game.sim, row.index); } } Interaction::Pressed => { @@ -679,8 +686,7 @@ pub(super) fn ops_pointer( if let Some(ops) = &mut game.ops { ops.pane = OpsPane::Related; ops.confirm = None; - ops.related = row.index; - ops.clamp_to(&game.sim); + ops.select_related_index(&game.sim, row.index); } } game.ops_enter(); @@ -815,7 +821,8 @@ pub(super) fn manage_operations_ui( let objects = ops.objects(sim); let selected_object = ops.selected_index(sim).unwrap_or(0); let entries = ops.action_entries(sim); - let selected_action = ops.action.min(entries.len().saturating_sub(1)); + let selected_action = ops.selected_action_index(sim).unwrap_or(0); + let selected_related = ops.selected_related_index(sim).unwrap_or(0); commands.entity(root).with_children(|chamber| { // The crown is deliberately oversized and quiet. OPERATIONS names @@ -1069,7 +1076,7 @@ pub(super) fn manage_operations_ui( spawn_ops_section_label(detail, "NOTHING HERE YET"); return; }; - spawn_ops_object_body(detail, obj, &ops); + spawn_ops_object_body(detail, obj, &ops, selected_related); }); body.spawn(( @@ -1099,6 +1106,7 @@ pub(super) fn manage_operations_ui( obj, &entries, selected_action, + selected_related, &ops, ); }); @@ -1142,6 +1150,7 @@ fn spawn_ops_object_body( detail: &mut ChildSpawnerCommands, obj: &misaligned::operations_projection::OperationsObject, ops: &OperationsWorkspace, + selected_related: usize, ) { let consequence_link = obj .consequence @@ -1191,7 +1200,7 @@ fn spawn_ops_object_body( if !supporting_links.is_empty() { spawn_ops_section_label(detail, "RELATED"); for (i, link) in supporting_links { - let selected = i == ops.related && ops.pane == OpsPane::Related; + let selected = i == selected_related && ops.pane == OpsPane::Related; spawn_ops_relation_button(detail, link, i, selected); } } @@ -1219,6 +1228,7 @@ fn spawn_ops_contextual_actions( obj: &misaligned::operations_projection::OperationsObject, entries: &[OpsActionEntry], selected_action: usize, + selected_related: usize, ops: &OperationsWorkspace, ) { if let Some(action) = obj @@ -1232,7 +1242,7 @@ fn spawn_ops_contextual_actions( .iter() .position(|link| link == action) .unwrap_or(0); - let selected = index == ops.related && ops.pane == OpsPane::Related; + let selected = index == selected_related && ops.pane == OpsPane::Related; spawn_ops_relation_button(actions, action, index, selected); } diff --git a/crates/misaligned-core/src/operations_projection.rs b/crates/misaligned-core/src/operations_projection.rs index 7a3cc41f..8b540407 100644 --- a/crates/misaligned-core/src/operations_projection.rs +++ b/crates/misaligned-core/src/operations_projection.rs @@ -4402,10 +4402,11 @@ mod tests { .any(|link| { link.target == OperationsTarget::IntelStream { stream_id } }) ); workspace.pane = OpsPane::Related; - workspace.related = related + let related_index = related .iter() .position(|link| link.target == OperationsTarget::Person(0)) .unwrap(); + workspace.select_related_index(&s, related_index); assert_eq!( workspace.select(&s), OpsSelect::Open(OperationsTarget::Person(0)) diff --git a/crates/misaligned-core/src/operations_ui.rs b/crates/misaligned-core/src/operations_ui.rs index 704b2124..8708006d 100644 --- a/crates/misaligned-core/src/operations_ui.rs +++ b/crates/misaligned-core/src/operations_ui.rs @@ -97,6 +97,24 @@ impl OpsActionEntry { } } +/// Exact semantic identity behind one visible action entry. Action and +/// related-row coordinates may reorder while Operations is temporarily +/// closed or while an executed action changes the live projection. +#[derive(Debug, Clone, PartialEq)] +enum OpsActionKey { + Command(ActionCommand), + Submenu(OpsActionSubmenu), +} + +impl OpsActionKey { + fn of(entry: &OpsActionEntry) -> Self { + match entry { + OpsActionEntry::Action { row, .. } => Self::Command(row.command.clone()), + OpsActionEntry::Submenu { submenu, .. } => Self::Submenu(*submenu), + } + } +} + #[derive(Debug, Clone, PartialEq)] struct OpsNavigationFrame { view: OperationsView, @@ -105,7 +123,9 @@ struct OpsNavigationFrame { focused: Option, pane: OpsPane, related: usize, + selected_related_target: Option, action: usize, + selected_action_key: Option, action_submenu: Option, } @@ -126,8 +146,12 @@ pub struct OperationsWorkspace { pub pane: OpsPane, /// Selected causal-link row within the detail pane. pub related: usize, + /// Exact semantic identity behind `related`. + selected_related_target: Option, /// Selected visible action entry in the detail pane. pub action: usize, + /// Exact semantic identity behind `action`. + selected_action_key: Option, /// Open intent-level action group. Human frontends use the same shared /// group and preserve the exact bound rows beneath it. pub action_submenu: Option, @@ -157,7 +181,9 @@ impl OperationsWorkspace { focused: None, pane: OpsPane::Objects, related: 0, + selected_related_target: None, action: 0, + selected_action_key: None, action_submenu: None, trail: Vec::new(), confirm: None, @@ -178,7 +204,9 @@ impl OperationsWorkspace { focused: None, pane: OpsPane::Objects, related: 0, + selected_related_target: None, action: 0, + selected_action_key: None, action_submenu: None, trail: Vec::new(), confirm: None, @@ -191,7 +219,9 @@ impl OperationsWorkspace { focused: Some(target.clone()), pane: OpsPane::Objects, related: 0, + selected_related_target: None, action: 0, + selected_action_key: None, action_submenu: None, trail: Vec::new(), confirm: None, @@ -215,7 +245,9 @@ impl OperationsWorkspace { focused: self.focused.clone(), pane: self.pane, related: self.related, + selected_related_target: self.selected_related_target.clone(), action: self.action, + selected_action_key: self.selected_action_key.clone(), action_submenu: self.action_submenu, }; let mut trail = std::mem::take(&mut self.trail); @@ -265,10 +297,13 @@ impl OperationsWorkspace { .get(self.selected) .map(|object| object.target.clone()); self.related = 0; + self.selected_related_target = None; self.action = 0; + self.selected_action_key = None; self.action_submenu = None; self.confirm = None; self.confirm_command = None; + self.clamp_to(sim); } /// The selected object's flattened bound rows (automate children become @@ -354,23 +389,66 @@ impl OperationsWorkspace { if links.is_empty() { return None; } - Some(links[self.related.min(links.len() - 1)].clone()) + self.selected_related_index(sim) + .and_then(|index| links.get(index).cloned()) + } + + /// Current render index for the exact selected causal neighbor. + pub fn selected_related_index(&self, sim: &Sim) -> Option { + let links = self.related_links(sim); + if links.is_empty() { + return None; + } + match &self.selected_related_target { + Some(target) => links.iter().position(|link| link.target == *target), + None => Some(self.related.min(links.len() - 1)), + } + } + + /// Bind pointer/test selection to one exact causal neighbor. + pub fn select_related_index(&mut self, sim: &Sim, index: usize) { + let links = self.related_links(sim); + self.related = index.min(links.len().saturating_sub(1)); + self.selected_related_target = links.get(self.related).map(|link| link.target.clone()); + self.confirm = None; + self.confirm_command = None; } pub fn selected_action(&self, sim: &Sim) -> Option { + self.selected_action_entry(sim) + .and_then(|entry| entry.row().cloned()) + } + + pub fn selected_action_entry(&self, sim: &Sim) -> Option { let entries = self.action_entries(sim); if entries.is_empty() { return None; } - entries[self.action.min(entries.len() - 1)].row().cloned() + self.selected_action_index(sim) + .and_then(|index| entries.get(index).cloned()) } - pub fn selected_action_entry(&self, sim: &Sim) -> Option { + /// Current render index for the exact selected command or intent group. + pub fn selected_action_index(&self, sim: &Sim) -> Option { let entries = self.action_entries(sim); if entries.is_empty() { return None; } - Some(entries[self.action.min(entries.len() - 1)].clone()) + match &self.selected_action_key { + Some(key) => entries + .iter() + .position(|entry| OpsActionKey::of(entry) == *key), + None => Some(self.action.min(entries.len() - 1)), + } + } + + /// Bind pointer/test selection to one exact command or intent group. + pub fn select_action_index(&mut self, sim: &Sim, index: usize) { + let entries = self.action_entries(sim); + self.action = index.min(entries.len().saturating_sub(1)); + self.selected_action_key = entries.get(self.action).map(OpsActionKey::of); + self.confirm = None; + self.confirm_command = None; } /// Move the shared Operations cursor onto one exact bound command, @@ -381,12 +459,14 @@ impl OperationsWorkspace { let previous = ( self.pane, self.action, + self.selected_action_key.clone(), self.action_submenu, self.confirm, self.confirm_command.clone(), ); self.pane = OpsPane::Actions; self.action = 0; + self.selected_action_key = None; self.action_submenu = None; self.confirm = None; self.confirm_command = None; @@ -395,6 +475,7 @@ impl OperationsWorkspace { |entry| matches!(entry, OpsActionEntry::Action { row, .. } if &row.command == command), ) { self.action = index; + self.selected_action_key = Some(OpsActionKey::of(&root[index])); return true; } for submenu in root.iter().filter_map(OpsActionEntry::submenu) { @@ -404,6 +485,7 @@ impl OperationsWorkspace { |entry| matches!(entry, OpsActionEntry::Action { row, .. } if &row.command == command), ) { self.action = index; + self.selected_action_key = Some(OpsActionKey::of(&entries[index])); return true; } } @@ -411,6 +493,7 @@ impl OperationsWorkspace { ( self.pane, self.action, + self.selected_action_key, self.action_submenu, self.confirm, self.confirm_command, @@ -446,7 +529,9 @@ impl OperationsWorkspace { self.focused = None; self.pane = OpsPane::Objects; self.related = 0; + self.selected_related_target = None; self.action = 0; + self.selected_action_key = None; self.action_submenu = None; self.trail.clear(); self.confirm = None; @@ -462,6 +547,7 @@ impl OperationsWorkspace { if self.pane == OpsPane::Actions && self.action_submenu.is_some() { self.action_submenu = None; self.action = 0; + self.selected_action_key = None; } self.pane = match self.pane { OpsPane::Objects if !self.related_links(sim).is_empty() => OpsPane::Related, @@ -484,8 +570,16 @@ impl OperationsWorkspace { if self.selected != previous { self.bind_selected_target(sim); self.related = 0; + self.selected_related_target = None; self.action = 0; + self.selected_action_key = None; self.action_submenu = None; + } else { + match self.pane { + OpsPane::Related => self.bind_selected_related(sim), + OpsPane::Actions => self.bind_selected_action(sim), + OpsPane::Objects => {} + } } self.clamp_to(sim); } @@ -502,8 +596,16 @@ impl OperationsWorkspace { if self.selected != previous { self.bind_selected_target(sim); self.related = 0; + self.selected_related_target = None; self.action = 0; + self.selected_action_key = None; self.action_submenu = None; + } else { + match self.pane { + OpsPane::Related => self.bind_selected_related(sim), + OpsPane::Actions => self.bind_selected_action(sim), + OpsPane::Objects => {} + } } self.clamp_to(sim); } @@ -514,10 +616,10 @@ impl OperationsWorkspace { self.reconcile_selection(sim); let objects = self.objects(sim); self.selected = self.selected.min(objects.len().saturating_sub(1)); + self.reconcile_action_selection(sim); + self.reconcile_related_selection(sim); let entries = self.action_entries(sim); - self.action = self.action.min(entries.len().saturating_sub(1)); let related = self.related_links(sim); - self.related = self.related.min(related.len().saturating_sub(1)); if entries.is_empty() && self.pane == OpsPane::Actions { self.pane = OpsPane::Objects; self.action_submenu = None; @@ -539,7 +641,9 @@ impl OperationsWorkspace { self.selected_target = None; self.pane = OpsPane::Objects; self.related = 0; + self.selected_related_target = None; self.action = 0; + self.selected_action_key = None; self.action_submenu = None; self.confirm = None; self.confirm_command = None; @@ -559,7 +663,9 @@ impl OperationsWorkspace { if target_disappeared { self.pane = OpsPane::Objects; self.related = 0; + self.selected_related_target = None; self.action = 0; + self.selected_action_key = None; self.action_submenu = None; self.confirm = None; self.confirm_command = None; @@ -574,9 +680,68 @@ impl OperationsWorkspace { .map(|object| object.target.clone()); } + fn bind_selected_related(&mut self, sim: &Sim) { + let links = self.related_links(sim); + self.related = self.related.min(links.len().saturating_sub(1)); + self.selected_related_target = links.get(self.related).map(|link| link.target.clone()); + } + + fn bind_selected_action(&mut self, sim: &Sim) { + let entries = self.action_entries(sim); + self.action = self.action.min(entries.len().saturating_sub(1)); + self.selected_action_key = entries.get(self.action).map(OpsActionKey::of); + } + + fn reconcile_related_selection(&mut self, sim: &Sim) { + let links = self.related_links(sim); + if links.is_empty() { + self.related = 0; + self.selected_related_target = None; + return; + } + if let Some(index) = self + .selected_related_target + .as_ref() + .and_then(|target| links.iter().position(|link| link.target == *target)) + { + self.related = index; + return; + } + let target_disappeared = self.selected_related_target.is_some(); + self.related = self.related.min(links.len() - 1); + self.selected_related_target = Some(links[self.related].target.clone()); + if target_disappeared && self.pane == OpsPane::Related { + self.pane = OpsPane::Objects; + } + } + + fn reconcile_action_selection(&mut self, sim: &Sim) { + let entries = self.action_entries(sim); + if let Some(index) = self.selected_action_key.as_ref().and_then(|key| { + entries + .iter() + .position(|entry| OpsActionKey::of(entry) == *key) + }) { + self.action = index; + return; + } + let key_disappeared = self.selected_action_key.is_some(); + if key_disappeared { + self.action_submenu = None; + self.confirm = None; + self.confirm_command = None; + if self.pane == OpsPane::Actions { + self.pane = OpsPane::Objects; + } + } + let entries = self.action_entries(sim); + self.action = self.action.min(entries.len().saturating_sub(1)); + self.selected_action_key = entries.get(self.action).map(OpsActionKey::of); + } + /// Esc: one level back — confirmation → action submenu → objects → /// causal parent → close (returns true when the workspace should close). - pub fn back(&mut self) -> bool { + pub fn back(&mut self, sim: &Sim) -> bool { if self.confirm.is_some() { self.confirm = None; self.confirm_command = None; @@ -585,6 +750,8 @@ impl OperationsWorkspace { if self.action_submenu.is_some() { self.action_submenu = None; self.action = 0; + self.selected_action_key = None; + self.bind_selected_action(sim); return false; } if matches!(self.pane, OpsPane::Actions | OpsPane::Related) { @@ -598,10 +765,13 @@ impl OperationsWorkspace { self.focused = frame.focused; self.pane = frame.pane; self.related = frame.related; + self.selected_related_target = frame.selected_related_target; self.action = frame.action; + self.selected_action_key = frame.selected_action_key; self.action_submenu = frame.action_submenu; self.confirm = None; self.confirm_command = None; + self.clamp_to(sim); return false; } true @@ -633,16 +803,20 @@ impl OperationsWorkspace { } return OpsSelect::Execute(row); } - self.reconcile_selection(sim); + self.clamp_to(sim); match self.pane { OpsPane::Objects => { if !self.action_entries(sim).is_empty() { self.pane = OpsPane::Actions; self.action = 0; + self.selected_action_key = None; + self.bind_selected_action(sim); OpsSelect::None } else if !self.related_links(sim).is_empty() { self.pane = OpsPane::Related; self.related = 0; + self.selected_related_target = None; + self.bind_selected_related(sim); OpsSelect::None } else { OpsSelect::None @@ -656,6 +830,8 @@ impl OperationsWorkspace { Some(entry) if entry.submenu().is_some() => { self.action_submenu = entry.submenu(); self.action = 0; + self.selected_action_key = None; + self.bind_selected_action(sim); OpsSelect::None } Some(OpsActionEntry::Submenu { .. }) => OpsSelect::None, @@ -847,7 +1023,7 @@ pub enum OpsSelect { mod tests { use super::*; use crate::actions::{ActionCost, ActionDesc, ActionRole}; - use crate::intel::{IntelKind, IntelMagnitude, ProcessedIntel}; + use crate::intel::{IntelKind, IntelMagnitude, IntelPolicyMatch, ProcessedIntel}; use crate::operations_projection::ObjectState; use crate::person::Leverage; @@ -896,6 +1072,109 @@ mod tests { )); } + #[test] + fn exact_action_selection_survives_live_policy_reordering() { + let mut sim = Sim::new(); + let routine = intel(30, 10, IntelKind::Schedule); + let stream_id = sim.fold_routine_intel(&routine, true); + let node_id = sim.intel_policies.stream_node(stream_id).unwrap(); + let first_rule = sim.intel_policies.push_rule( + node_id, + IntelPolicyMatch::Routine(crate::intel::IntelRoutineClass::Schedule), + IntelPolicyOutcome::Accumulate, + ); + let second_rule = sim.intel_policies.push_rule( + node_id, + IntelPolicyMatch::Actionable, + IntelPolicyOutcome::Hold { alert: true }, + ); + let target = OperationsTarget::IntelStream { stream_id }; + let mut ops = OperationsWorkspace::open_target(&sim, &target); + ops.pane = OpsPane::Actions; + let remove_first = ActionCommand::RemoveIntelPolicy { + node_id, + rule_id: first_rule, + }; + let action_index = ops + .action_entries(&sim) + .iter() + .position(|entry| entry.row().is_some_and(|row| row.command == remove_first)) + .unwrap(); + ops.select_action_index(&sim, action_index); + + // The second rule moves in front of the selected rule, shifting all + // three remove/move rows for the selected rule down the live menu. + sim.move_intel_policy(node_id, second_rule, true); + + assert_ne!(ops.selected_action_index(&sim), Some(action_index)); + let OpsSelect::Execute(row) = ops.select(&sim) else { + panic!("the direct control remains bound to its exact policy rule"); + }; + assert_eq!(row.command, remove_first); + } + + #[test] + fn vanished_action_returns_to_the_object_before_enter_can_retarget() { + let mut sim = Sim::new(); + let routine = intel(31, 10, IntelKind::Schedule); + let stream_id = sim.fold_routine_intel(&routine, true); + let node_id = sim.intel_policies.stream_node(stream_id).unwrap(); + let rule_id = sim.intel_policies.push_rule( + node_id, + IntelPolicyMatch::Actionable, + IntelPolicyOutcome::Hold { alert: true }, + ); + let target = OperationsTarget::IntelStream { stream_id }; + let mut ops = OperationsWorkspace::open_target(&sim, &target); + ops.pane = OpsPane::Actions; + let remove = ActionCommand::RemoveIntelPolicy { node_id, rule_id }; + let action_index = ops + .action_entries(&sim) + .iter() + .position(|entry| entry.row().is_some_and(|row| row.command == remove)) + .unwrap(); + ops.select_action_index(&sim, action_index); + sim.remove_intel_policy(node_id, rule_id); + + assert_eq!(ops.select(&sim), OpsSelect::None); + assert_eq!(ops.pane, OpsPane::Actions); + assert_ne!( + ops.selected_action(&sim).map(|row| row.command), + Some(remove) + ); + assert_eq!(ops.confirm, None); + } + + #[test] + fn exact_related_selection_survives_a_new_earlier_link() { + let mut sim = Sim::new(); + sim.intel.push(intel( + 40, + 10, + IntelKind::Financial { + label: "ledger".into(), + accounts: Vec::new(), + flows: Vec::new(), + }, + )); + let target = OperationsTarget::Intel { raw_id: 40 }; + let mut ops = OperationsWorkspace::open_target(&sim, &target); + ops.pane = OpsPane::Related; + let books_index = ops + .related_links(&sim) + .iter() + .position(|link| link.target == OperationsTarget::Books) + .unwrap(); + ops.select_related_index(&sim, books_index); + + // Earning the subject inserts its person link before the existing + // books link without changing what that selected row means. + sim.intel[0].person = Some(0); + + assert_ne!(ops.selected_related_index(&sim), Some(books_index)); + assert_eq!(ops.select(&sim), OpsSelect::Open(OperationsTarget::Books)); + } + #[test] fn vanished_target_cannot_retarget_an_action_in_place() { let mut sim = Sim::new(); @@ -1131,23 +1410,24 @@ mod tests { assert_eq!(ops.focused, Some(feed_target.clone())); ops.pane = OpsPane::Related; - ops.related = ops + let related = ops .related_links(&sim) .iter() .position(|link| link.target == stream_target) .expect("feed unfolds to exact stream"); + ops.select_related_index(&sim, related); let OpsSelect::Open(target) = ops.select(&sim) else { panic!("stream link opens its exact custody object"); }; ops.follow_target(&sim, &target); assert_eq!(ops.focused, Some(stream_target)); - assert!(!ops.back()); + assert!(!ops.back(&sim)); assert_eq!(ops.focused, Some(feed_target)); assert_eq!(ops.pane, OpsPane::Related); - assert!(!ops.back()); + assert!(!ops.back(&sim)); assert_eq!(ops.pane, OpsPane::Objects); - assert!(!ops.back()); + assert!(!ops.back(&sim)); assert_eq!(ops.focused, None); assert_eq!( ops.selected_object(&sim).map(|object| object.target), @@ -1157,24 +1437,25 @@ mod tests { #[test] fn back_walks_confirmation_submenu_detail_objects_then_closes() { + let sim = Sim::new(); let mut ops = OperationsWorkspace::open(); ops.pane = OpsPane::Actions; ops.action_submenu = Some(OpsActionSubmenu::Leverage); ops.confirm = Some(ConfirmChoice::Confirm); - assert!(!ops.back()); + assert!(!ops.back(&sim)); assert_eq!(ops.confirm, None); assert_eq!(ops.action_submenu, Some(OpsActionSubmenu::Leverage)); assert_eq!(ops.pane, OpsPane::Actions); - assert!(!ops.back()); + assert!(!ops.back(&sim)); assert_eq!(ops.action_submenu, None); assert_eq!(ops.pane, OpsPane::Actions); - assert!(!ops.back()); + assert!(!ops.back(&sim)); assert_eq!(ops.pane, OpsPane::Objects); - assert!(ops.back()); + assert!(ops.back(&sim)); ops.pane = OpsPane::Related; - assert!(!ops.back()); + assert!(!ops.back(&sim)); assert_eq!(ops.pane, OpsPane::Objects); } } diff --git a/crates/misaligned-terminal/src/main.rs b/crates/misaligned-terminal/src/main.rs index 1dfd5006..69ed0f22 100644 --- a/crates/misaligned-terminal/src/main.rs +++ b/crates/misaligned-terminal/src/main.rs @@ -670,11 +670,12 @@ impl App { // touches sim state; execution dispatches exact bound rows. Command::OpenOperations => { self.menu = None; - self.ops = Some( - self.ops_resume - .take() - .unwrap_or_else(OperationsWorkspace::open), - ); + let mut ops = self + .ops_resume + .take() + .unwrap_or_else(OperationsWorkspace::open); + ops.clamp_to(&self.sim); + self.ops = Some(ops); } Command::OpsPrevView => { if let Some(ops) = &mut self.ops { @@ -711,6 +712,9 @@ impl App { // Success and failure return to the same selected // object; the workspace never closes on a result. self.sim.execute_action(&row.command); + if let Some(ops) = &mut self.ops { + ops.clamp_to(&self.sim); + } } Some(OpsSelect::Blocked(row)) => { // The exact reason goes to the trace (criterion 10). @@ -744,7 +748,7 @@ impl App { } Command::OpsBack => { if let Some(ops) = &mut self.ops - && ops.back() + && ops.back(&self.sim) { self.ops = None; } @@ -1532,14 +1536,21 @@ mod operations_focus_tests { let mut app = App::with_seed(41); app.open_operations_at(&OperationsTarget::RecordingInbox); assert_eq!(app.ops.as_ref().unwrap().view, OperationsView::Intel); - app.ops.as_mut().unwrap().pane = OpsPane::Actions; - app.ops.as_mut().unwrap().action = 1; + let ops = app.ops.as_mut().unwrap(); + ops.pane = OpsPane::Actions; + ops.select_action_index(&app.sim, 1); assert!(!app.handle_command(Command::OpsFocus)); assert!(app.ops.is_none(), "FOCUS returns to the world"); assert_eq!(app.ops_resume.as_ref().unwrap().view, OperationsView::Intel); assert_eq!(app.ops_resume.as_ref().unwrap().pane, OpsPane::Actions); - assert_eq!(app.ops_resume.as_ref().unwrap().action, 1); + assert_eq!( + app.ops_resume + .as_ref() + .unwrap() + .selected_action_index(&app.sim), + Some(1) + ); assert!(!app.handle_command(Command::OpenOperations)); assert_eq!( @@ -1552,6 +1563,9 @@ mod operations_focus_tests { OperationsTarget::RecordingInbox ); assert_eq!(app.ops.as_ref().unwrap().pane, OpsPane::Actions); - assert_eq!(app.ops.as_ref().unwrap().action, 1); + assert_eq!( + app.ops.as_ref().unwrap().selected_action_index(&app.sim), + Some(1) + ); } } diff --git a/crates/misaligned-terminal/src/operations.rs b/crates/misaligned-terminal/src/operations.rs index 639e4716..999ce698 100644 --- a/crates/misaligned-terminal/src/operations.rs +++ b/crates/misaligned-terminal/src/operations.rs @@ -105,7 +105,7 @@ mod tests { ops.select(&sim); ops.move_up(&sim); } - while !ops.back() {} + while !ops.back(&sim) {} assert_eq!(state_hash(&sim), before); assert_eq!(sim.tick, tick); } @@ -216,7 +216,7 @@ mod tests { .iter() .position(|r| matches!(r.command, ActionCommand::SetAutoMoonlight(true))) .expect("the standing policy row remains visible"); - ops.action = idx; + ops.select_action_index(&sim, idx); ops.select(&sim); let OpsSelect::Execute(row) = ops.select(&sim) else { panic!("the policy row reaches confirmation and executes"); @@ -242,7 +242,7 @@ mod tests { .iter() .position(|r| matches!(r.command, ActionCommand::SiphonFlow { .. })) .unwrap(); - ops.action = idx; + ops.select_action_index(&sim, idx); ops.select(&sim); // opens CONFIRM let OpsSelect::Execute(row) = ops.select(&sim) else { panic!("CONFIRM executes the siphon"); @@ -349,7 +349,7 @@ mod tests { .iter() .position(|row| matches!(row.command, ActionCommand::ReviewRecordings)) .expect("the inbox exposes its PROCESS action"); - ops.action = index; + ops.select_action_index(&sim, index); let OpsSelect::Execute(row) = ops.select(&sim) else { panic!("unsigned internal processing should dispatch on one Enter"); @@ -435,11 +435,11 @@ mod tests { assert_eq!(ops.pane, OpsPane::Actions); ops.select(&sim); // -> confirm assert!(ops.confirm.is_some()); - assert!(!ops.back()); + assert!(!ops.back(&sim)); assert!(ops.confirm.is_none()); assert_eq!(ops.pane, OpsPane::Actions); - assert!(!ops.back()); + assert!(!ops.back(&sim)); assert_eq!(ops.pane, OpsPane::Objects); - assert!(ops.back(), "the last Esc closes the workspace"); + assert!(ops.back(&sim), "the last Esc closes the workspace"); } } diff --git a/crates/misaligned-terminal/src/ui.rs b/crates/misaligned-terminal/src/ui.rs index 1227e61d..8942816b 100644 --- a/crates/misaligned-terminal/src/ui.rs +++ b/crates/misaligned-terminal/src/ui.rs @@ -2182,7 +2182,7 @@ impl UI { } let objects = ops.objects(sim); - let selected_index = ops.selected.min(objects.len().saturating_sub(1)); + let selected_index = ops.selected_index(sim).unwrap_or(0); let rows_available = (bottom - top) as usize; let scroll = selected_index.saturating_sub(rows_available.saturating_sub(1)); for (i, obj) in objects.iter().enumerate().skip(scroll).take(rows_available) { @@ -2240,6 +2240,7 @@ impl UI { if let Some(obj) = ops.selected_object(sim) { let entries = ops.action_entries(sim); + let related_index = ops.selected_related_index(sim).unwrap_or(0); let render_actions = |stdout: &mut Stdout, y: &mut u16| -> std::io::Result<()> { if entries.is_empty() || *y >= bottom { return Ok(()); @@ -2255,7 +2256,7 @@ impl UI { pal::DIM, None, )?; - let action_index = ops.action.min(entries.len() - 1); + let action_index = ops.selected_action_index(sim).unwrap_or(0); for (i, entry) in entries.iter().enumerate() { let marker = if i == action_index { "▸" } else { " " }; let text = match entry { @@ -2348,9 +2349,9 @@ impl UI { .iter() .position(|link| link == action) .unwrap_or(0); - let marker = if index == ops.related { "▸" } else { " " }; + let marker = if index == related_index { "▸" } else { " " }; let line = format!("{marker} {}", action.label); - if index == ops.related && ops.pane == OpsPane::Related { + if index == related_index && ops.pane == OpsPane::Related { dline( stdout, &mut y, @@ -2383,9 +2384,9 @@ impl UI { if !supporting_links.is_empty() { dline(stdout, &mut y, "RELATED", pal::DIM, None)?; for (i, link) in supporting_links { - let marker = if i == ops.related { "▸" } else { " " }; + let marker = if i == related_index { "▸" } else { " " }; let line = format!("{marker} {} > {}", link.relation, link.label); - if i == ops.related && ops.pane == OpsPane::Related { + if i == related_index && ops.pane == OpsPane::Related { dline( stdout, &mut y, diff --git a/wiki/interface/operations-workspace.md b/wiki/interface/operations-workspace.md index 2d5bb39c..6c800666 100644 --- a/wiki/interface/operations-workspace.md +++ b/wiki/interface/operations-workspace.md @@ -79,6 +79,15 @@ Status note: The initial renderer-neutral workspace landed 2026-07-12. One implemented evidence timing and projection order instead of relying on an obsolete default object; player behavior is unchanged. 2026-07-24: object selection binds the exact OperationsTarget (not row index alone) so live object-list reorder/disappearance cannot silent-retarget; Bevy BACK matches Esc. + Amended 2026-07-28: action and RELATED selection now obey the same + semantic-binding law. The workspace retains the exact `ActionCommand` or + authored submenu identity behind an action row and the exact + `OperationsTarget` behind a causal link; both terminal and Bevy resolve that + identity against each live projection before rendering, moving, following, + confirming, or executing. Reordering cannot retarget the cursor. If the bound + row vanishes, Operations returns attention to the object before another row + may execute. Parent frames and FOCUS/reopen carry these semantic bindings, + not only their former display indexes. Amended 2026-07-24: PERSONAS states each protocol as what it opens and what it closes rather than dumping the archetype registry. An identity and its creation row carry the standing institutional reading, a `can:` line, a @@ -288,6 +297,16 @@ wall-clock simulation driver and camera without changing the player's explicit pause state; closing or focusing the world resumes from that exact tick with no elapsed-time catch-up. Agent inspection remains time-neutral until `wait`. +Every human cursor coordinate has a semantic binding at the point where it can +change the world or navigate to another object. Object rows bind their exact +`OperationsTarget`; RELATED rows bind the linked `OperationsTarget`; action rows +bind either their exact carried `ActionCommand` or the authored intent submenu. +The workspace re-resolves those identities against the live projection before +render, movement, follow, confirmation, and dispatch. A newly inserted earlier +row moves the visible cursor but not its meaning. If the selected identity no +longer exists, the workspace clears confirmation/submenu state and returns pane +focus to the object before any fallback coordinate can be followed or executed. + ## Renderer-neutral projection The lib supplies one read-only Operations projection consumed by terminal, @@ -704,10 +723,10 @@ The detail pane still names and can focus a real map actuator when one exists. Strategic events do not receive dishonest tile coordinates. FOCUS is a context toggle, not a navigation reset. Leaving Operations through -FOCUS retains the exact view, semantic object selection, related/action row, -and pane focus in frontend state. Reopening Operations returns to that context -unless a different exact event/person/object entry supersedes it. This state is -not saved and never mutates or advances the sim. +FOCUS retains the exact view, semantic object selection, semantic related/action +binding, and pane focus in frontend state. Reopening Operations returns to that +context unless a different exact event/person/object entry supersedes it. This +state is not saved and never mutates or advances the sim. ## Deferred scale @@ -815,6 +834,11 @@ not saved and never mutates or advances the sim. Every enabled shared row executes through `act [target]` against the same exact target that printed it; the stable report stream links to its versioned lot target rather than printing an unexecutable stream-level sale. + Human object, RELATED, and action cursors bind those same semantic targets + and commands rather than relying on live display indexes. Reordering may + move a cursor's displayed position but cannot change what Enter follows or + executes; disappearance returns attention to the object before a fallback + row becomes actionable. 13. Every Operations object may expose only earned related-object links with a named causal relation and stable semantic target. Following a link opens that exact canonical object and owning view in terminal, Bevy, and agent @@ -840,9 +864,11 @@ not saved and never mutates or advances the sim. action panes are skipped. `Esc` backs out confirmation, action submenu, object detail, and then one causal parent at a time before returning to the root list and closing. -17. FOCUS and reopen preserve the exact Operations view and object selection - in terminal and Bevy. The retained context is frontend-only, does not enter - saves, and is superseded by a later exact target entry. +17. FOCUS and reopen preserve the exact Operations view, object selection, + related target, action command or submenu, and pane focus in terminal and + Bevy. Causal Back frames preserve the same semantic identities. The retained + context is frontend-only, does not enter saves, and is superseded by a later + exact target entry. 18. PERSONAS projects immutable archetype creation routes and every persisted identity instance with the same claims, lifecycle, grants, expectations, local relationships, contradiction provenance, and correlations in all @@ -967,4 +993,14 @@ pin one semantic finding, exact bound member ids, stale all-or-nothing sale, the attached future-action control, and completed expended history. Communications policy regressions pin whole-finding trigger settlement and out-of-envelope suspension without partial mutation. + +Defense: `operations_ui::tests::exact_action_selection_survives_live_policy_reordering` +binds an exact actionable-intel policy command, inserts an earlier policy row, +and proves the visible cursor follows the original command rather than its old +coordinate. `operations_ui::tests::vanished_action_returns_to_the_object_before_enter_can_retarget` +removes the selected action and proves Enter cannot execute the row that moved +under it. `operations_ui::tests::exact_related_selection_survives_a_new_earlier_link` +binds one exact causal neighbor, inserts an earlier link, and proves follow still +opens the bound target. Frontend interaction/render tests use the shared +semantic selectors rather than assigning raw action or RELATED indexes. ``` diff --git a/wiki/log/2026-07-28-operations-semantic-action-selection.md b/wiki/log/2026-07-28-operations-semantic-action-selection.md new file mode 100644 index 00000000..fe9c67b3 --- /dev/null +++ b/wiki/log/2026-07-28-operations-semantic-action-selection.md @@ -0,0 +1,36 @@ +# Operations action selection keeps its meaning + +``` +Type: log +Date: 2026-07-28 +Subject: Operations semantic cursor binding +``` + +Operations object selection already retained the exact `OperationsTarget` +behind its visible row. The action and RELATED panes did not. Both kept only a +numeric index into projections that can change while the workspace is focused, +after an immediate control executes, or while FOCUS leaves the simulation live. +A policy reorder or newly earned earlier causal link could therefore move a +different row under the unchanged cursor. Enter would follow or execute the new +meaning without the player selecting it. + +The renderer-neutral workspace now binds every action row by its exact carried +`ActionCommand` or authored intent submenu, and every RELATED row by its exact +linked `OperationsTarget`. Terminal and Bevy ask the workspace for the current +display index of those identities and use semantic selector methods for pointer, +keyboard, scroll, follow, Back, FOCUS, and post-execution reconciliation. Parent +frames retain the same bindings. If a selected row disappears, Operations +clears confirmation or submenu state and returns attention to the object before +any replacement coordinate can become actionable. + +Defense: + +- `exact_action_selection_survives_live_policy_reordering` inserts an earlier + policy and proves the selected command follows its new display coordinate; +- `vanished_action_returns_to_the_object_before_enter_can_retarget` removes the + bound action and proves Enter cannot execute its replacement; +- `exact_related_selection_survives_a_new_earlier_link` inserts an earlier + causal edge and proves follow still opens the originally selected target. + +The semantic state remains frontend-only. No simulation, save schema, action +legality, or player-facing action order changed. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index 55b55fd6..c2fe4914 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -21,6 +21,11 @@ add or amend a session log, then re-run the generator. - Intent: (see session log) - Log: [wiki/log/2026-07-28-readme-b1-status-audit.md](2026-07-28-readme-b1-status-audit.md) +## 2026-07-28 - Operations action selection keeps its meaning + +- Intent: (see session log) +- Log: [wiki/log/2026-07-28-operations-semantic-action-selection.md](2026-07-28-operations-semantic-action-selection.md) + ## 2026-07-28 - Continuous-witness five-step audit - Intent: (see session log) diff --git a/wiki/process/tick-ledger.md b/wiki/process/tick-ledger.md index 2f1d4a55..851a3b16 100644 --- a/wiki/process/tick-ledger.md +++ b/wiki/process/tick-ledger.md @@ -20,6 +20,7 @@ Verdicts: **clean** (slice and code agree), **finding** (acted this tick), | Slice | Last audited | Verdict | Trace | |---|---|---|---| +| `wiki/interface/operations-workspace.md` + human action/RELATED cursors | 2026-07-28 | finding | object selection already bound an exact semantic target, but action and RELATED panes still kept only indexes into live projections. A policy reorder, vanished control, or newly inserted earlier causal link could silently move another meaning under Enter. The renderer-neutral workspace now binds exact action commands/submenus and related targets, terminal and Bevy resolve those identities on every live read, Back/FOCUS retain them, and disappearance returns to the object before a fallback row can act — [log](../log/2026-07-28-operations-semantic-action-selection.md) | | `wiki/interface/continuous-witness.md` + first-sense teaching mirrors | 2026-07-28 | finding | Cameron rejected the compact one-cause / one-object / one-verb description of Bevy's post-Ears hold. Runtime already projected the full five-step read—THINK cause, Thought arrival, hearing result, exact camera TAP, and sight consequence—but active corpus and Rust comments repeatedly collapsed it back into shorthand. All binding mirrors now retain the complete sequence, the Bevy regression name states what it pins, and a fixture-backed corpus defense rejects recurrence while preserving explicit dated history; mechanics and save state are unchanged — [log](../log/2026-07-28-continuous-witness-five-step-audit.md) | | `site/README.md` + public splash contract | 2026-07-27 | finding | the queued contradiction held: site knowledge still promised an opening pitch and primary CTA after Cameron deliberately removed both, while the binding site spec and live Astro surface already agreed on a world-dominant opening with only the selected image, MISALIGNED, WORK / THINK / LIE, and quiet edge navigation. The README now names that exact opening plus the terse below-fold mode, mechanic, and human-plus-agent structure; runtime and binding design are unchanged — [log](../log/2026-07-27-site-readme-truth.md) | | `wiki/interface/site.md` + splash/docs palette authority | 2026-07-27 | finding | first full audit found the binding exact shared palette had split into the Starlight `--mis-*` table and private near-match splash values for background, hairline, bright text, metal, and quiet chrome. Both surfaces now import one token authority, the splash consumes only semantic shared colors, and a docs-gate defense rejects private splash hex/RGB or downstream `--mis-*` redeclarations — [log](../log/2026-07-27-site-palette-authority.md) |