diff --git a/crates/misaligned-bevy/src/main.rs b/crates/misaligned-bevy/src/main.rs index d88b9cc1..33765035 100644 --- a/crates/misaligned-bevy/src/main.rs +++ b/crates/misaligned-bevy/src/main.rs @@ -106,11 +106,10 @@ use world_annotations::{ CalloutGizmos, CursorMarker, DigitalFocusLabel, HoverVerbBar, HoverVerbFamily, HoverVerbGroup, HoverVerbReceipt, HoverVerbReceiptText, HoverVerbWord, OperatorCue, PersonEvidenceMarker, PersonMarker, PersonWorkMarker, READ_SENTENCE_FONT_PX, ReadSentenceMarker, TokenMarker, - callout_anchor_at, draw_read_callout_leaders, hover_verb_bar_anchor, read_callout_blocks, - render_cursor, render_digital_focus_label, - render_hover_identity, render_hover_verb_bar, render_operator_cue, render_people, - render_person_evidence_markers, render_person_work_markers, render_read_sentences, - render_tokens, setup_callout_gizmos, setup_hover_identity, + annotation_redirect, draw_read_callout_leaders, hover_verb_bar_anchor, render_cursor, + render_digital_focus_label, render_hover_identity, render_hover_verb_bar, render_operator_cue, + render_people, render_person_evidence_markers, render_person_work_markers, + render_read_sentences, render_tokens, setup_callout_gizmos, setup_hover_identity, }; const TILE_SIZE: f32 = 16.0; @@ -2471,37 +2470,6 @@ type RealCameraQuery<'w, 's> = Query< /// Mouse targeting in the material render: cast the cursor ray onto the /// floor plane (y == 0) — the same tile coordinates as the flat picker. -/// The tile a flat-view click means, letting a callout stand in for the -/// thing it annotates. Callouts are offset off their subject so they never -/// overprint matter (digital-read.md callout grammar), which otherwise turns -/// the natural aim — the note naming the rack — into a floor click that only -/// moves attention. DIGITAL only, and only where the annotation layer draws. -fn callout_redirect( - game: &Game, - read_altitude: &ReadAltitude, - mode: &RenderMode, - windows: &Query<&Window, With>, - camera_q: &Query<(&Camera, &GlobalTransform), With>, - projection_q: &Query<&Projection, With>, -) -> Option<(i32, i32)> { - if mode.material || world_overlays_suppressed(game) { - return None; - } - let window = windows.single().ok()?; - let cursor = window.cursor_position()?; - if cursor.x >= window.width() - SIDEBAR_WIDTH { - return None; - } - let (camera, camera_tf) = camera_q.single().ok()?; - let world = camera.viewport_to_world_2d(camera_tf, cursor).ok()?; - let cam_scale = match projection_q.single().ok()? { - Projection::Orthographic(ortho) => ortho.scale, - _ => 1.0, - }; - let blocks = read_callout_blocks(game, read_altitude.minimum); - callout_anchor_at(&blocks, cam_scale, world) -} - fn mouse_grid_real( windows: &Query<&Window, With>, camera_q: &RealCameraQuery, @@ -2675,7 +2643,7 @@ fn handle_input( let callout_tile = (mouse.just_released(MouseButton::Left) || mouse.just_pressed(MouseButton::Right)) .then(|| { - callout_redirect( + annotation_redirect( &game, &read_altitude, &mode, diff --git a/crates/misaligned-bevy/src/world_annotations.rs b/crates/misaligned-bevy/src/world_annotations.rs index e34dc8de..7d0bc4f9 100644 --- a/crates/misaligned-bevy/src/world_annotations.rs +++ b/crates/misaligned-bevy/src/world_annotations.rs @@ -690,6 +690,140 @@ pub(super) fn render_read_sentences( } } +/// Approximate advance width of one ASCII glyph at the callout font size. +/// Deliberately generous: an over-wide hit box costs a little forgiveness +/// around the note, while an under-wide one puts aim back on the floor. +const CALLOUT_GLYPH_WIDTH_PX: f32 = READ_SENTENCE_FONT_PX * 0.62; + +/// World-space rectangle each callout occupies, paired with the anchor tile +/// a click inside it means. +/// +/// The offset that stopped callouts overprinting matter also moved them +/// away from the small body they describe, so aim that reads as "click the +/// thing the note is about" was landing on floor instead (2026-07-22 Co GUI +/// playtest). A note is a handle on its subject: the rectangle resolves to +/// the anchor tile, keeping context-menu.md's "actions live on the thing" +/// true for the annotation layer as well as the chassis. +pub(super) fn callout_hit_rects( + blocks: &[ReadCalloutBlock], + cam_scale: f32, +) -> Vec<(Rect, (i32, i32))> { + blocks + .iter() + .map(|block| { + let lines: Vec<&str> = block.text.lines().collect(); + let cols = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0); + let width = cols as f32 * CALLOUT_GLYPH_WIDTH_PX * cam_scale; + let height = lines.len() as f32 * READ_SENTENCE_LINE_PX * cam_scale; + // Same corner the renderer places the text block at. + let center = grid_to_world(block.tile.0, block.tile.1, 0.0).truncate(); + let offset = callout_corner_offset(block.quadrant); + let corner = center + + Vec2::new( + offset.x, + offset.y + block.quadrant.stack_sign() * block.lift_px * cam_scale, + ); + // The block hangs off its near corner, growing away from the + // anchor — mirror `callout_text_anchor` exactly. + let (min, max) = match block.quadrant { + CalloutQuadrant::NorthEast => (corner, corner + Vec2::new(width, height)), + CalloutQuadrant::NorthWest => ( + corner - Vec2::new(width, 0.0), + corner + Vec2::new(0.0, height), + ), + CalloutQuadrant::SouthEast => ( + corner - Vec2::new(0.0, height), + corner + Vec2::new(width, 0.0), + ), + CalloutQuadrant::SouthWest => (corner - Vec2::new(width, height), corner), + }; + (Rect::from_corners(min, max), block.tile) + }) + .collect() +} + +/// World-space rectangle of each drawn token chip, paired with the machine +/// tile it reports on. The chip sits in the slot just beyond the machine's +/// upper-right focus corner, which is far enough that aiming at an exposure +/// count was landing on the neighbouring floor tile — the same acquisition +/// failure the read callouts had, on the shorter offset. +pub(super) fn token_chip_rect(tile: (i32, i32), label: &str) -> Rect { + let layout = token_marker_layout(label); + let min = grid_to_world(tile.0, tile.1, 0.0).truncate() + layout.offset; + let size = Vec2::new( + label.chars().count() as f32 * layout.font_size * 0.62, + layout.font_size, + ); + Rect::from_corners(min, min + size) +} + +pub(super) fn token_chip_hit_rects(game: &Game) -> Vec<(Rect, (i32, i32))> { + game.sim + .compute + .machines + .iter() + .filter_map(|machine| { + let (label, _) = game + .sim + .work_stack_at(machine.x, machine.y) + .and_then(token_label_and_color)?; + let tile = (machine.x, machine.y); + Some((token_chip_rect(tile, &label), tile)) + }) + .collect() +} + +/// The anchor tile a world-space pointer position means, when it rests on +/// any annotation drawn beside its subject. Token chips are offered first +/// because they sit closest to the body; callout blocks stack outward, so +/// within each list the first containing rectangle wins. +pub(super) fn annotation_anchor_at( + chips: &[(Rect, (i32, i32))], + callouts: &[(Rect, (i32, i32))], + world: Vec2, +) -> Option<(i32, i32)> { + chips + .iter() + .chain(callouts.iter()) + .find(|(rect, _)| rect.contains(world)) + .map(|(_, tile)| *tile) +} + +/// The tile a flat-view click means, letting a callout stand in for the +/// thing it annotates. Callouts are offset off their subject so they never +/// overprint matter (digital-read.md callout grammar), which otherwise turns +/// the natural aim — the note naming the rack — into a floor click that only +/// moves attention. DIGITAL only, and only where the annotation layer draws. +pub(super) fn annotation_redirect( + game: &Game, + read_altitude: &ReadAltitude, + mode: &RenderMode, + windows: &Query<&Window, With>, + camera_q: &Query<(&Camera, &GlobalTransform), With>, + projection_q: &Query<&Projection, With>, +) -> Option<(i32, i32)> { + if mode.material || world_overlays_suppressed(game) { + return None; + } + let window = windows.single().ok()?; + let cursor = window.cursor_position()?; + if cursor.x >= window.width() - SIDEBAR_WIDTH { + return None; + } + let (camera, camera_tf) = camera_q.single().ok()?; + let world = camera.viewport_to_world_2d(camera_tf, cursor).ok()?; + let cam_scale = match projection_q.single().ok()? { + Projection::Orthographic(ortho) => ortho.scale, + _ => 1.0, + }; + let blocks = read_callout_blocks(game, read_altitude.minimum); + annotation_anchor_at( + &token_chip_hit_rects(game), + &callout_hit_rects(&blocks, cam_scale), + world, + ) +} + /// Thin leader lines binding each callout to its anchor tile: drawn once /// per anchor from the tile edge to the nearest block's corner, on the /// dedicated 1px group. DIGITAL only; yields wherever world overlays do. @@ -1390,13 +1524,16 @@ pub(super) fn setup_hover_identity(mut commands: Commands) { } #[allow(clippy::type_complexity)] +#[allow(clippy::too_many_arguments)] pub(super) fn render_hover_identity( game: Res, mode: Res, harness: Option>, windows: Query<&Window, With>, camera_q: Query<(&Camera, &GlobalTransform), With>, + projection_q: Query<&Projection, With>, camera3_q: RealCameraQuery, + read_altitude: Res, mut label: Query< (&mut Text, &mut Node, &mut Visibility), (With, Without), @@ -1412,7 +1549,17 @@ pub(super) fn render_hover_identity( } else if mode.material { mouse_grid_real(&windows, &camera3_q) } else { - mouse_grid(&windows, &camera_q) + // Resting on an annotation names the thing that annotation is about, + // so the chip and the click agree about what the pointer means. + annotation_redirect( + &game, + &read_altitude, + &mode, + &windows, + &camera_q, + &projection_q, + ) + .or_else(|| mouse_grid(&windows, &camera_q)) }; let Some(((x, y), line)) = hover_identity_target(&game, pointer) else { *visibility = Visibility::Hidden; @@ -1722,6 +1869,121 @@ mod hover_verb_bar_tests { } } +#[cfg(test)] +mod callout_hit_tests { + use super::*; + + fn block(quadrant: CalloutQuadrant, lift_px: f32) -> ReadCalloutBlock { + ReadCalloutBlock { + text: "1 record pending".to_string(), + held: false, + tile: (10, 10), + quadrant, + lift_px, + leads: lift_px == 0.0, + } + } + + /// The note is a handle on its subject: a pointer inside the drawn text + /// resolves to the anchor tile, not the floor beneath the text. + #[test] + fn a_pointer_on_the_callout_means_its_anchor() { + for quadrant in CalloutQuadrant::PREFERENCE { + let blocks = [block(quadrant, 0.0)]; + let rects = callout_hit_rects(&blocks, 1.0); + let (rect, tile) = rects[0]; + assert_eq!(tile, (10, 10)); + assert_eq!( + annotation_anchor_at(&[], &callout_hit_rects(&blocks, 1.0), rect.center()), + Some((10, 10)), + "{quadrant:?} callout center must resolve to its anchor" + ); + } + } + + /// Empty space keeps its ordinary meaning — the redirect is a hit box on + /// drawn text, never a claim over the quadrant it was placed in. + #[test] + fn empty_map_space_is_not_captured() { + let blocks = [block(CalloutQuadrant::NorthEast, 0.0)]; + let anchor_center = grid_to_world(10, 10, 0.0).truncate(); + assert_eq!( + annotation_anchor_at(&[], &callout_hit_rects(&blocks, 1.0), anchor_center), + None + ); + // Well outside the block, still inside the scanned quadrant. + let far = anchor_center + Vec2::new(TILE_SIZE * 3.0, TILE_SIZE * 2.8); + assert_eq!( + annotation_anchor_at(&[], &callout_hit_rects(&blocks, 1.0), far), + None + ); + } + + /// The hit box tracks the same corner and stack lift the renderer uses, + /// so a stacked second sentence is grabbable where it actually draws. + #[test] + fn stacked_blocks_keep_separate_hit_boxes() { + let blocks = [ + block(CalloutQuadrant::NorthEast, 0.0), + block(CalloutQuadrant::NorthEast, 40.0), + ]; + let rects = callout_hit_rects(&blocks, 1.0); + assert!( + rects[1].0.min.y > rects[0].0.min.y, + "a lifted block must sit above the one it stacks on" + ); + assert_eq!( + annotation_anchor_at(&[], &callout_hit_rects(&blocks, 1.0), rects[1].0.center()), + Some((10, 10)) + ); + } + + /// The exposure count the 2026-07-22 playtest aimed at sits a chip slot + /// off the chassis, so it needs the same handle treatment as a sentence. + #[test] + fn a_token_chip_means_its_machine() { + let tile = (10, 10); + let rect = token_chip_rect(tile, "!3"); + let chips = [(rect, tile)]; + assert_eq!( + annotation_anchor_at(&chips, &[], rect.center()), + Some(tile), + "clicking the exposure count means the machine it counts for" + ); + // The chip is beside the body, not over it: the machine's own tile + // center stays outside the chip's box (the placement law it obeys). + let body = grid_to_world(tile.0, tile.1, 0.0).truncate(); + assert!(!rect.contains(body)); + } + + /// A chip sits closest to the body, so it answers before a callout whose + /// block happens to overlap the same point. + #[test] + fn a_chip_outranks_an_overlapping_callout() { + let chip_tile = (10, 10); + let rect = token_chip_rect(chip_tile, "!3"); + let chips = [(rect, chip_tile)]; + let callouts = [( + Rect::from_corners(rect.min - Vec2::splat(40.0), rect.max + Vec2::splat(40.0)), + (2, 2), + )]; + assert_eq!( + annotation_anchor_at(&chips, &callouts, rect.center()), + Some(chip_tile) + ); + } + + /// Text holds one on-screen size across zoom, so its world-space hit box + /// must scale with the camera or aim drifts off it when zoomed out. + #[test] + fn hit_boxes_scale_with_the_camera() { + let blocks = [block(CalloutQuadrant::NorthEast, 0.0)]; + let near = callout_hit_rects(&blocks, 1.0)[0].0; + let far = callout_hit_rects(&blocks, 2.0)[0].0; + assert!(far.width() > near.width() && far.height() > near.height()); + } +} + #[cfg(test)] mod read_callout_tests { use super::*; diff --git a/wiki/interface/digital-read.md b/wiki/interface/digital-read.md index 7281e797..d5204aba 100644 --- a/wiki/interface/digital-read.md +++ b/wiki/interface/digital-read.md @@ -178,6 +178,22 @@ that perception rendered. stays bone. All placement decisions are pure functions of sim state, never of the live pointer or wall clock, so staged screenshot evidence stays deterministic. + **An annotation is a handle on its subject (amended 2026-07-23).** Because + the offset moves a note off the small body it names, the drawn annotation + is itself a target: a left or right click inside a read callout **or a + token chip** resolves to its anchor tile, exactly as if the anchor were + clicked, and the pointer identity chip names that same subject rather than + the floor under the note. This keeps context-menu.md's "actions live on the + thing" true for the annotation layer — reading a sentence or an exposure + count and clicking it selects, or opens the menu of, the thing it is about, + and hover and click never disagree about what the pointer means. The hit + region is the drawn text only (callout boxes scale with the camera, since + their type holds one on-screen size); chips answer before callouts because + they sit closest to the body; the surrounding quadrant keeps its ordinary + meaning, and marquee drags stay spatial. Amended after the 2026-07-22 GUI + playtest found the 2026-07-21 offsets had turned the natural aim — the note + naming the rack, and the exposure count beside it — into floor clicks that + only moved attention. - **Trace on the wire.** Pending one-shot signatures render as discrete records sitting on their emitting anchors/carriers, aging toward the observer who samples that channel; scrub visibly consumes them. The diff --git a/wiki/log/2026-07-23-callout-click-targets.md b/wiki/log/2026-07-23-callout-click-targets.md new file mode 100644 index 00000000..8c0b67c9 --- /dev/null +++ b/wiki/log/2026-07-23-callout-click-targets.md @@ -0,0 +1,51 @@ +# 2026-07-23 — Annotations became handles on their subjects + +``` +Type: log +``` + +## Intent + +The 2026-07-21 callout grammar stopped world annotations from overprinting +the matter they describe, but the 2026-07-22 Co GUI playtest found the +cost: the `NOW/PROCESS` cue and the exposure count now float far enough +from the small rack body that aiming at them lands on floor, which only +moves attention and recenters the camera. The tester "repeatedly clicked +floor" and eventually lost the host. The fix that removed one legibility +failure had introduced an acquisition failure. + +## Changed + +- A left or right click inside a drawn annotation resolves to that + annotation's anchor tile. Clicking the sentence about Rack 3, or the + crimson exposure count beside it, selects Rack 3; right-clicking either + opens Rack 3's menu. +- Both annotation families are covered: read callouts + (`callout_hit_rects`, camera-scaled because their type holds one + on-screen size) and token chips (`token_chip_rect`). Chips answer first + because they sit closest to the body. +- The pointer identity chip uses the same redirect, so hover and click + agree — resting on the exposure count names the rack rather than the + floor beneath the note. +- The decision is a pure function (`annotation_anchor_at`) over gathered + rectangles; `annotation_redirect` does the impure gathering once per + real click, so a resting pointer never pays for the layout + recomputation. Empty quadrant space keeps its ordinary meaning and + marquee drags stay spatial. +- `wiki/interface/digital-read.md` (Type: spec, owner of world-annotation + placement) carries the amended clause. + +## Verification + +`cargo test -p misaligned-bevy` (123 passed, six of them new: anchor +resolution in all four quadrants, empty space staying uncaptured, stacked +blocks keeping separate boxes, hit boxes scaling with zoom, a token chip +resolving to its machine while staying off the body, and chips +outranking an overlapping callout) and `./tools/check.sh --frontend`. + +Observed in the real window on the pre-hover build: right-clicking an +exposure chip floating a full tile off the rack opened Rack 3's ACTIONS +menu, and left-clicking it selected the rack (the WORK / THINK / LIE +strip appeared). The pointer-identity half of the change shipped after +that run and is covered by tests only — the next play session should +confirm the chip names the rack rather than the floor. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index d9d27bb2..b0d3bd0e 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -51,6 +51,11 @@ add or amend a session log, then re-run the generator. - Intent: Move every one-shot `Financial` signature out of ambient detection debt and onto the exact accounting carrier that already authors the Lab's financial record mail. The consequence should reach Priya through ordinary custody, remain stoppable only before her read, and become ir... - Log: [wiki/log/2026-07-23-financial-evidence-route.md](2026-07-23-financial-evidence-route.md) +## 2026-07-23 - Annotations became handles on their subjects + +- Intent: The 2026-07-21 callout grammar stopped world annotations from overprinting the matter they describe, but the 2026-07-22 Co GUI playtest found the cost: the `NOW/PROCESS` cue and the exposure count now float far enough from the small rack body that aiming at them lands on floor... +- Log: [wiki/log/2026-07-23-callout-click-targets.md](2026-07-23-callout-click-targets.md) + ## 2026-07-22 - one clinical palette authority - Intent: (see session log)