From 1b3c401a1948cb6f17209d3b2b336993d5e977bb Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Sat, 12 Sep 2026 04:34:02 -0700 Subject: [PATCH] Keep allocation destinations readable without blocking collapsed header drops --- crates/oa-desktop/src/allocation_drag.rs | 41 +--- .../oa-desktop/src/allocation_drag/tests.rs | 82 ++----- crates/oa-desktop/src/app/allocation_drag.rs | 45 ++-- .../app/allocation_drag_full_tree_tests.rs | 220 +++++++++++++++++- .../src/app/allocation_drag_tests.rs | 134 ++++++++++- crates/oa-desktop/src/diagram/work_drag.rs | 24 +- crates/oa-desktop/src/views.rs | 27 ++- 7 files changed, 416 insertions(+), 157 deletions(-) diff --git a/crates/oa-desktop/src/allocation_drag.rs b/crates/oa-desktop/src/allocation_drag.rs index 2c226677..6817e831 100644 --- a/crates/oa-desktop/src/allocation_drag.rs +++ b/crates/oa-desktop/src/allocation_drag.rs @@ -416,19 +416,11 @@ impl Rect { && other.position.x + other.size.width <= self.position.x + self.size.width && other.position.y + other.size.height <= self.position.y + self.size.height } - fn overlaps(self, other: Self) -> bool { - self.position.x < other.position.x + other.size.width - && other.position.x < self.position.x + self.size.width - && self.position.y < other.position.y + other.size.height - && other.position.y < self.position.y + self.size.height - } } #[derive(Debug, Clone, Copy, PartialEq)] pub struct TargetPresentation { pub placement: Id, - /// Exact frozen painted label bounds, supplied by the renderer/resolver. - pub label: Rect, } #[derive(Debug, Clone, Copy, PartialEq)] @@ -526,29 +518,18 @@ pub fn place( let mut unfolded = None; if let Destination::Performer(performer) = plan.destination { let target = presentation.target.ok_or_else(|| { - Refusal::LayoutRequired("The frozen performer label must be visible.".into()) + Refusal::LayoutRequired("A visible accepted performer target is required.".into()) })?; let node = diagram .nodes .get_mut(&target.placement) .ok_or(Refusal::StaleScope)?; - let frozen_header = plan + let visible_target = plan .before_visible .nodes .iter() - .find(|n| n.id == target.placement) - .map(|n| Rect { - position: n.position, - size: Size { - width: n.size.width, - height: n.size.height.min(62.), - }, - }); - if node.element != performer - || frozen_header.is_none_or(|header| !header.contains(target.label)) - || !target.label.valid() - || !presentation.viewport.contains(target.label) - { + .any(|n| n.id == target.placement && n.element == performer); + if node.element != performer || !visible_target { return Err(Refusal::LayoutRequired( "Make room or adjust the view, then drag again.".into(), )); @@ -589,18 +570,8 @@ pub fn place( "Make room or adjust the view, then drag again.".into(), )); } - // A performer may legitimately enclose the work, but its frozen feedback - // label is painted above the candidate. Check their joint readability after - // projection, including same-performer moves; never shift either rectangle. - if presentation - .target - .is_some_and(|target| header.overlaps(target.label)) - { - return Err(Refusal::LayoutRequired( - "The work header overlaps the performer label. Aim below or beside the label, then drag again." - .into(), - )); - } + // Destination identity lives in a fixed toolbar slot. Frozen hit geometry + // and its outline never introduce an opaque label over this work header. Ok(PlacementPlan { diagram, view, diff --git a/crates/oa-desktop/src/allocation_drag/tests.rs b/crates/oa-desktop/src/allocation_drag/tests.rs index cfd391b1..2c15c9f5 100644 --- a/crates/oa-desktop/src/allocation_drag/tests.rs +++ b/crates/oa-desktop/src/allocation_drag/tests.rs @@ -53,16 +53,7 @@ fn presentation(p: &Project, d: Id, target: Option) -> PresentationContext { let target = target.map(|element| { let view = projection::project_view(p, d); let node = view.nodes.iter().find(|n| n.element == element).unwrap(); - TargetPresentation { - placement: node.id, - label: Rect { - position: node.position, - size: Size { - width: node.size.width.min(224.), - height: 62., - }, - }, - } + TargetPresentation { placement: node.id } }); PresentationContext { viewport: Rect { @@ -1008,7 +999,7 @@ fn collapsed_target_unfold_is_explicit_and_no_other_fold_changes() { } #[test] -fn target_presentation_must_name_the_destination_and_its_visible_frozen_header() { +fn target_presentation_requires_accepted_identity_and_selected_header_visibility() { let f = fixture(Layer::Oa); let plan = semantic(&f, Destination::Performer(f.a)); assert!(matches!( @@ -1016,13 +1007,13 @@ fn target_presentation_must_name_the_destination_and_its_visible_frozen_header() Err(Refusal::LayoutRequired(_)) )); let mut context = presentation(&f.p, f.d, Some(f.a)); - context.target.as_mut().unwrap().label.position = xy(0., 0.); + context.target.as_mut().unwrap().placement = f.n; assert!(matches!( place(&plan, xy(150., 200.), context), Err(Refusal::LayoutRequired(_)) )); let mut clipped = presentation(&f.p, f.d, Some(f.a)); - clipped.viewport.position.y = 180.; + clipped.viewport.position.y = 220.; assert!(matches!( place(&plan, xy(150., 200.), clipped), Err(Refusal::LayoutRequired(_)) @@ -1030,78 +1021,34 @@ fn target_presentation_must_name_the_destination_and_its_visible_frozen_header() } #[test] -fn frozen_performer_label_refuses_overlapping_work_header_and_allows_nearby_exact_move() { +fn toolbar_identity_allows_exact_incoming_placement_at_old_label_overlap() { for layer in [Layer::Oa, Layer::Sa] { let mut f = fixture(layer); f.p.elements.get_mut(&f.a).unwrap().name = "Receiver".into(); let plan = semantic(&f, Destination::Performer(f.a)); - let mut context = presentation(&f.p, f.d, Some(f.a)); - // The actual frozen renderer bounds for Receiver at (100, 100). - context.target.as_mut().unwrap().label = Rect { - position: xy(110., 131.), - size: Size { - width: 88., - height: 22., - }, - }; - let label = context.target.unwrap().label; - let counterexample = Rect { - position: xy(120., 110.), - size: Size { - width: 224., - height: 62., - }, - }; - assert!(context.viewport.contains(label)); - assert!(context.viewport.contains(counterexample)); - assert!( - label.contains(Rect { - position: xy(134., 145.), - size: Size { - width: 1., - height: 1. - }, - }), - "the formerly admitted work name begins underneath the label" - ); + let context = presentation(&f.p, f.d, Some(f.a)); let before = plan.project().clone(); - assert!(matches!( - place(&plan, xy(120., 105.), context), - Err(Refusal::LayoutRequired(message)) if message.contains("Aim below or beside") - )); + let placed = place(&plan, xy(120., 105.), context).unwrap(); + assert_eq!(placed.diagram.nodes[&f.n].position, xy(120., 110.)); assert_eq!(plan.project(), &before); - - // A 50-unit lower pointer position clears the frozen label. The planner - // preserves the user's snapped position instead of packing either card. - let placed = place(&plan, xy(120., 155.), context).unwrap(); - assert_eq!(placed.diagram.nodes[&f.n].position, xy(120., 160.)); let after = roundtrip(&f.p, finish(&plan, &f.p, &placed).unwrap().unwrap()); assert_eq!(validation::effective_performer(&after, f.w), Some(f.a)); - assert_eq!(after.diagrams[&f.d].nodes[&f.n].position, xy(120., 160.)); + assert_eq!(after.diagrams[&f.d].nodes[&f.n].position, xy(120., 110.)); assert_eq!(after.elements, f.p.elements); assert_eq!(after.diagrams[&f.second], f.p.diagrams[&f.second]); } } #[test] -fn frozen_label_readability_allows_touching_edges_but_refuses_positive_area_overlap() { +fn selected_header_visibility_allows_viewport_edge_but_refuses_clipping() { let f = fixture(Layer::Oa); let plan = semantic(&f, Destination::Performer(f.a)); let mut context = presentation(&f.p, f.d, Some(f.a)); - context.target.as_mut().unwrap().label = Rect { - position: xy(110., 131.), - size: Size { - width: 90., - height: 19., - }, - }; + context.viewport.position.y = 100.; for (position, allowed) in [ - (xy(190., 110.), false), - (xy(200., 110.), true), - (xy(210., 110.), true), - (xy(120., 140.), false), - (xy(120., 150.), true), - (xy(120., 160.), true), + (xy(120., 90.), false), + (xy(120., 100.), true), + (xy(120., 110.), true), ] { let result = place(&plan, position, context); assert_eq!(result.is_ok(), allowed, "{position:?}: {result:?}"); @@ -1110,7 +1057,6 @@ fn frozen_label_readability_allows_touching_edges_but_refuses_positive_area_over } } } - #[test] fn same_performer_readability_preserves_snapped_noop_and_allocation_identity() { for layer in [Layer::Oa, Layer::Sa] { diff --git a/crates/oa-desktop/src/app/allocation_drag.rs b/crates/oa-desktop/src/app/allocation_drag.rs index 2f7c72ef..1a6f14da 100644 --- a/crates/oa-desktop/src/app/allocation_drag.rs +++ b/crates/oa-desktop/src/app/allocation_drag.rs @@ -41,7 +41,6 @@ pub struct Target { pub element: Id, pub placement: Id, pub region: Rectangle, - pub label: plan::Rect, pub name: String, pub ancestors: Vec, } @@ -95,22 +94,7 @@ impl Frozen { ancestors.push(id); parent = project.elements.get(&id).and_then(|e| e.parent); } - // This is also the exact frozen label painted above the ghost. - let name = crate::views::short( - project.named(e.id), - (((region.width - 28.).max(10.) / 10.) as usize).min(34), - ); - let label = plan::Rect { - position: Position { - x: region.x + 10., - y: region.y + 31., - }, - size: Size { - width: (name.chars().count() as f32 * 10. + 8.) - .min((region.width - 20.).max(1.)), - height: 22., - }, - }; + let name = project.named(e.id).to_owned(); let collapsed = scope.diagram.nodes[&n.id].collapsed; targets.push(Target { element: e.id, @@ -123,7 +107,6 @@ impl Frozen { } else { region }, - label, name, ancestors, }); @@ -281,6 +264,30 @@ pub struct Feedback { pub text: String, pub refused: bool, } +impl Feedback { + /// Captured accepted names, independent of clipped status/refusal prose. + pub fn destination_identity(&self) -> String { + match self.targets.as_slice() { + [] => if self.press.arrange { + "Arrange only" + } else if self.refused { + "No eligible destination" + } else { + "Outside performers" + } + .into(), + [index] => self.press.frozen.targets[*index].name.clone(), + indices => format!( + "Ambiguous: {}", + indices + .iter() + .map(|index| self.press.frozen.targets[*index].name.as_str()) + .collect::>() + .join(" / ") + ), + } + } +} #[derive(Default)] pub struct State { pub epoch: u64, @@ -552,7 +559,7 @@ impl App { viewport: stamp.viewport(), zoom: stamp.zoom, target: target.map(|i| { let t = &press.frozen.targets[i]; - plan::TargetPresentation { placement: t.placement, label: t.label } + plan::TargetPresentation { placement: t.placement } }), }; let placement = plan::place(&semantic, requested, presentation).map_err(|r| refusal(&r))?; diff --git a/crates/oa-desktop/src/app/allocation_drag_full_tree_tests.rs b/crates/oa-desktop/src/app/allocation_drag_full_tree_tests.rs index fd55b2fc..c10aa34c 100644 --- a/crates/oa-desktop/src/app/allocation_drag_full_tree_tests.rs +++ b/crates/oa-desktop/src/app/allocation_drag_full_tree_tests.rs @@ -275,7 +275,8 @@ async fn actual_rendered_canvas_pixels_do_not_change_when_toolbar_feedback_text_ .expect("active frozen destination"); assert!(!feedback.refused, "{}", feedback.text); let target = &feedback.press.frozen.targets[feedback.targets[0]]; - assert!(target.label.position.y * f.app.zoom + f.app.pan.y < 14.); + assert!(target.region.y * f.app.zoom + f.app.pan.y < 0.); + assert_eq!(feedback.destination_identity(), "A"); let canvas = canvas_bounds(&mut mounted, &f.app); let first = mounted.pixels(&f.app); f.app.allocation_drag.feedback.as_mut().unwrap().text = @@ -285,6 +286,221 @@ async fn actual_rendered_canvas_pixels_do_not_change_when_toolbar_feedback_text_ assert_eq!( canvas_pixels(&first, f.app.window_size.width as usize, canvas), canvas_pixels(&second, f.app.window_size.width as usize, canvas), - "status must not obscure any candidate header or frozen target label" + "status must not obscure any candidate header or destination identity" ); } + +fn destination_bounds(mounted: &mut Mounted, app: &App) -> Rectangle { + mounted.geometry(app).bounds("allocation-destination") +} +fn assert_on_screen(bounds: Rectangle, app: &App) { + assert!( + bounds.x >= 0. && bounds.y >= 0. && bounds.width >= 400. && bounds.height == 26., + "{bounds:?}" + ); + assert!( + bounds.x + bounds.width <= app.window_size.width + && bounds.y + bounds.height <= app.window_size.height, + "{bounds:?}" + ); +} + +#[tokio::test] +async fn actual_app_tree_exact_incoming_drag_keeps_destination_visible_and_canvas_stable() { + for layer in [Layer::Oa, Layer::Sa] { + for (minimum, name, collapsed, zoom, pan) in [ + (false, "Receiver", false, 1., Vector::ZERO), + (false, "Receiver", false, 0.75, Vector::new(37., -15.)), + ( + false, + "Receiving subsystem", + true, + 1.25, + Vector::new(-30., 23.), + ), + ( + false, + "Receiving subsystem", + true, + 1., + Vector::new(0., -100.), + ), + (true, "Receiving subsystem", true, 1., Vector::ZERO), + ( + true, + "Receiving subsystem WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW", + true, + 1., + Vector::new(0., -100.), + ), + ] { + let mut f = fixture(layer); + let mut project = f.app.project().clone(); + project.elements.get_mut(&f.a).unwrap().name = name.into(); + let diagram = project.diagrams.get_mut(&f.diagram).unwrap(); + let target = diagram + .nodes + .values_mut() + .find(|n| n.element == f.a) + .unwrap(); + target.collapsed = collapsed; + let target_id = target.id; + // At minimum size, keep the source in the visible canvas; preserve + // the same standard (40,35) name grab and original target geometry. + let source_y = if minimum { 210. } else { 500. }; + diagram.nodes.get_mut(&f.node).unwrap().position.y = source_y; + replace(&mut f, project); + f.app.layer = layer; + f.app.window_size = if minimum { + iced::Size::new(1100., 700.) + } else { + iced::Size::new(2000., 1400.) + }; + let _ = f.app.update(Message::Select(f.work)); + let mut mounted = Mounted::new(&mut f.app).await; + f.app.zoom = zoom; + f.app.pan = pan; + f.app.fit_to_view = false; + mounted.settle(&mut f.app).await; + let canvas = canvas_bounds(&mut mounted, &f.app); + let slot = destination_bounds(&mut mounted, &f.app); + assert_on_screen(slot, &f.app); + assert!(slot.y + slot.height <= canvas.y); + let idle = mounted.pixels(&f.app); + let before = f.app.project().clone(); + let source = f.app.document.bundle().clone(); + let from = at(&mut mounted, &f, xy(140., source_y + 35.)); + let to = at(&mut mounted, &f, xy(160., 140.)); + assert!( + canvas.contains(from) && canvas.contains(to), + "{canvas:?} {from:?} {to:?}" + ); + mounted + .batch( + &mut f.app, + &[ + Input::Mouse(mouse::Event::CursorMoved { position: from }), + Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), + Input::Mouse(mouse::Event::CursorMoved { position: to }), + ], + mouse::Cursor::Available(to), + ) + .await; + let feedback = f + .app + .allocation_drag + .feedback + .as_ref() + .expect(&f.app.status); + assert!(!feedback.refused, "{}", feedback.text); + assert_eq!(feedback.destination_identity(), name); + let preview = f.app.allocation_drag.preview.as_ref().expect(&f.app.status); + assert_eq!( + preview.placement.diagram.nodes[&f.node].position, + xy(120., 110.) + ); + assert!(!preview.placement.diagram.nodes[&target_id].collapsed); + let selected = preview + .placement + .view + .nodes + .iter() + .find(|node| node.id == f.node) + .unwrap(); + let selected_header = Rectangle::new( + Point::new( + canvas.x + selected.position.x * zoom + pan.x, + canvas.y + selected.position.y * zoom + pan.y, + ), + iced::Size::new( + selected.size.width * zoom, + selected.size.height.min(62.) * zoom, + ), + ); + assert!(canvas.contains(Point::new(selected_header.x, selected_header.y))); + assert!( + selected_header.x + selected_header.width <= canvas.x + canvas.width + && selected_header.y + selected_header.height <= canvas.y + canvas.height + ); + let mut expected = preview.semantic.project().clone(); + expected + .diagrams + .insert(f.diagram, preview.placement.diagram.clone()); + assert_eq!(f.app.project(), &before); + assert_eq!(f.app.document.bundle(), &source); + assert!(!f.app.document.can_undo()); + assert_eq!(canvas_bounds(&mut mounted, &f.app), canvas); + assert_eq!(destination_bounds(&mut mounted, &f.app), slot); + let active = mounted.pixels(&f.app); + let width = f.app.window_size.width as usize; + assert_ne!( + canvas_pixels(&idle, width, slot), + canvas_pixels(&active, width, slot), + "actual destination text is painted in its dedicated visible slot" + ); + f.app.allocation_drag.feedback.as_mut().unwrap().text = + "Long refusal/status prose must not obscure work or destination. ".repeat(30); + let verbose = mounted.pixels(&f.app); + assert_ne!(active, verbose); + assert_eq!( + canvas_pixels(&active, width, canvas), + canvas_pixels(&verbose, width, canvas) + ); + assert_eq!( + canvas_pixels(&active, width, slot), + canvas_pixels(&verbose, width, slot) + ); + assert_eq!(canvas_bounds(&mut mounted, &f.app), canvas); + if pan.y == -100. { + // A genuine clipping refusal in the same held gesture, then + // recovery at the original exact point: no camera rebasing. + let clipped = at(&mut mounted, &f, xy(160., 110.)); + mounted + .batch( + &mut f.app, + &[Input::Mouse(mouse::Event::CursorMoved { + position: clipped, + })], + mouse::Cursor::Available(clipped), + ) + .await; + assert!(f.app.allocation_drag.feedback.as_ref().unwrap().refused); + assert!(f.app.allocation_drag.preview.is_none()); + assert_eq!(canvas_bounds(&mut mounted, &f.app), canvas); + assert_eq!(destination_bounds(&mut mounted, &f.app), slot); + assert_eq!(f.app.project(), &before); + assert_eq!(f.app.document.bundle(), &source); + mounted + .batch( + &mut f.app, + &[Input::Mouse(mouse::Event::CursorMoved { position: to })], + mouse::Cursor::Available(to), + ) + .await; + assert!(!f.app.allocation_drag.feedback.as_ref().unwrap().refused); + } + mounted + .batch( + &mut f.app, + &[Input::Mouse(mouse::Event::ButtonReleased( + mouse::Button::Left, + ))], + mouse::Cursor::Available(to), + ) + .await; + assert_eq!(f.app.project(), &expected, "{}", f.app.status); + assert_eq!(f.app.document.revision(), 1); + assert_eq!(canvas_bounds(&mut mounted, &f.app), canvas); + assert_eq!(destination_bounds(&mut mounted, &f.app), slot); + let after = f.app.document.bundle().clone(); + let _ = f.app.update(Message::Undo); + assert_eq!(f.app.project(), &before); + assert_eq!(f.app.document.bundle(), &source); + assert!(!f.app.document.can_undo()); + let _ = f.app.update(Message::Redo); + assert_eq!(f.app.project(), &expected); + assert_eq!(f.app.document.bundle(), &after); + assert!(!f.app.document.can_redo()); + } + } +} diff --git a/crates/oa-desktop/src/app/allocation_drag_tests.rs b/crates/oa-desktop/src/app/allocation_drag_tests.rs index 6f86f2ce..abe16539 100644 --- a/crates/oa-desktop/src/app/allocation_drag_tests.rs +++ b/crates/oa-desktop/src/app/allocation_drag_tests.rs @@ -911,7 +911,7 @@ fn toolbar_status_leaves_transformed_top_edge_work_header_readable() { } #[test] -fn toolbar_status_leaves_transformed_top_edge_frozen_target_label_readable() { +fn toolbar_destination_names_transformed_target_whose_header_is_clipped() { for zoom in [0.75, 1.25] { let mut f = fixture(Layer::Oa); let mut project = f.app.project().clone(); @@ -940,11 +940,8 @@ fn toolbar_status_leaves_transformed_top_edge_frozen_target_label_readable() { let feedback = f.app.allocation_drag.feedback.as_ref().unwrap(); assert!(!feedback.refused, "{}", feedback.text); let target = &feedback.press.frozen.targets[feedback.targets[0]]; - let top = screen(target.label.position).y - bounds().y; - assert!( - top > 0. && top < 14., - "label starts in the formerly obscured band: {top}" - ); + assert!(screen(xy(target.region.x, target.region.y)).y < bounds().y); + assert_eq!(feedback.destination_identity(), "A"); assert!(f.app.allocation_drag.preview.is_some()); let _ = f.app.update(release); assert_eq!( @@ -955,7 +952,8 @@ fn toolbar_status_leaves_transformed_top_edge_frozen_target_label_readable() { } #[test] -fn oa_sa_label_overlap_refuses_preview_release_and_retries_without_partial_source_or_history() { +fn oa_sa_clipped_selected_header_refuses_preview_release_and_retries_without_partial_source_or_history() + { for layer in [Layer::Oa, Layer::Sa] { for (zoom, pan) in [ (1., Vector::ZERO), @@ -972,6 +970,7 @@ fn oa_sa_label_overlap_refuses_preview_release_and_retries_without_partial_sourc "Temporary".into(), ))); let _ = f.app.update(Message::Undo); + let pan = Vector::new(pan.x, pan.y - 100. * zoom); f.app.zoom = zoom; f.app.pan = pan; f.app.fit_to_view = false; @@ -982,7 +981,10 @@ fn oa_sa_label_overlap_refuses_preview_release_and_retries_without_partial_sourc ) }; let from = screen(xy(140., 535.)); - let overlap = screen(xy(160., 140.)); + // Keep the pointer inside the visible target even when the camera + // clips its top. Only the grabbed work header must be clipped. + let overlap = screen(xy(160., 110_f32.max(-pan.y / zoom + 5.))); + assert!(bounds().contains(overlap)); let clear = screen(xy(160., 190.)); let accepted = f.app.project().clone(); let source = f.app.document.bundle().clone(); @@ -994,7 +996,7 @@ fn oa_sa_label_overlap_refuses_preview_release_and_retries_without_partial_sourc assert!(f.app.allocation_drag.preview.is_none()); let feedback = f.app.allocation_drag.feedback.as_ref().unwrap(); assert!(feedback.refused, "{}", feedback.text); - assert!(feedback.text.contains("Aim below or beside")); + assert!(feedback.text.contains("Make room or adjust the view")); assert_eq!( feedback.press.frozen.targets[feedback.targets[0]].element, f.a @@ -1014,7 +1016,7 @@ fn oa_sa_label_overlap_refuses_preview_release_and_retries_without_partial_sourc assert!(!f.app.document.can_undo()); assert!(f.app.document.can_redo()); - // In a new gesture, moving out of the collision reuses its semantic + // In a new gesture, moving into the viewport reuses its semantic // candidate and stable allocation identity; only geometry changes. let mut events = batch(&f.app, from, overlap); events.pop(); @@ -1074,3 +1076,115 @@ fn oa_sa_label_overlap_refuses_preview_release_and_retries_without_partial_sourc } } } + +#[test] +fn oa_sa_toolbar_identity_accepts_exact_receiver_and_collapsed_header_drag() { + for layer in [Layer::Oa, Layer::Sa] { + for (name, collapsed) in [("Receiver", false), ("Receiving subsystem", true)] { + for (zoom, pan) in [ + (1., Vector::ZERO), + (0.75, Vector::new(37., -15.)), + (1.25, Vector::new(-30., 23.)), + (1., Vector::new(0., -100.)), + ] { + let mut f = fixture(layer); + let mut project = f.app.project().clone(); + project.elements.get_mut(&f.a).unwrap().name = name.into(); + let target = project + .diagrams + .get_mut(&f.diagram) + .unwrap() + .nodes + .values_mut() + .find(|n| n.element == f.a) + .unwrap(); + target.collapsed = collapsed; + let target_id = target.id; + replace(&mut f, project); + f.app.zoom = zoom; + f.app.pan = pan; + f.app.fit_to_view = false; + let screen = |world: Position| { + Point::new( + bounds().x + world.x * zoom + pan.x, + bounds().y + world.y * zoom + pan.y, + ) + }; + let accepted = f.app.project().clone(); + let source = f.app.document.bundle().clone(); + let snapshot = f.app.document.snapshot(); + // Normal name grab (40,35), release inside the original header. + let mut events = batch(&f.app, screen(xy(140., 535.)), screen(xy(160., 140.))); + let release = events.pop().unwrap(); + send(&mut f.app, events); + let feedback = f + .app + .allocation_drag + .feedback + .as_ref() + .expect(&f.app.status); + assert!(!feedback.refused, "{}", feedback.text); + assert_eq!(feedback.destination_identity(), name); + assert_eq!( + feedback.press.frozen.targets[feedback.targets[0]].placement, + target_id + ); + let preview = f.app.allocation_drag.preview.as_ref().expect(&f.app.status); + assert_eq!( + preview.placement.diagram.nodes[&f.node].position, + xy(120., 110.) + ); + assert!(!preview.placement.diagram.nodes[&target_id].collapsed); + assert_eq!( + preview.placement.view, + projection::project_diagram_with_index( + preview.semantic.project(), + &preview.placement.diagram, + preview.semantic.index() + ) + ); + let mut expected = preview.semantic.project().clone(); + expected + .diagrams + .insert(f.diagram, preview.placement.diagram.clone()); + assert_eq!(f.app.project(), &accepted); + assert_eq!(f.app.document.bundle(), &source); + assert!(Arc::ptr_eq(&snapshot, &f.app.document.snapshot())); + assert!(!f.app.document.can_undo()); + let _ = f.app.update(release); + assert_eq!(f.app.project(), &expected, "{}", f.app.status); + assert_eq!(f.app.document.revision(), 1); + assert_eq!( + validation::effective_performer(f.app.project(), f.work), + Some(f.a) + ); + let after = f.app.document.bundle().clone(); + let _ = f.app.update(Message::Undo); + assert_eq!(f.app.project(), &accepted); + assert_eq!(f.app.document.bundle(), &source); + assert!(!f.app.document.can_undo()); + let _ = f.app.update(Message::Redo); + assert_eq!(f.app.project(), &expected); + assert_eq!(f.app.document.bundle(), &after); + assert!(!f.app.document.can_redo()); + } + } + } +} + +#[test] +fn destination_identity_does_not_describe_blocked_or_outside_viewport_as_unallocation() { + let f = fixture(Layer::Oa); + let p = press(&f); + let mut feedback = Feedback { + press: p, + targets: vec![], + text: "A card blocks this drop".into(), + refused: true, + }; + assert_eq!(feedback.destination_identity(), "No eligible destination"); + feedback.text = "Release inside the captured canvas".into(); + assert_eq!(feedback.destination_identity(), "No eligible destination"); + feedback.refused = false; + assert_eq!(feedback.destination_identity(), "Outside performers"); +} diff --git a/crates/oa-desktop/src/diagram/work_drag.rs b/crates/oa-desktop/src/diagram/work_drag.rs index 8490ebb2..adb01d25 100644 --- a/crates/oa-desktop/src/diagram/work_drag.rs +++ b/crates/oa-desktop/src/diagram/work_drag.rs @@ -134,8 +134,8 @@ impl DiagramCanvas<'_> { } } -/// Separate passive layer: frozen target labels remain above all candidate card -/// text. It does not acquire input or use a second independent semantic scene. +/// Passive frozen target outlines. Destination identity is in the toolbar so +/// feedback cannot cover candidate text or change the target hit geometry. pub struct WorkFeedback<'a>(pub DiagramCanvas<'a>); impl canvas::Program for WorkFeedback<'_> { type State = (); @@ -179,26 +179,6 @@ impl canvas::Program for WorkFeedback<'_> { ), Stroke::default().with_color(color).with_width(3.), ); - let label = target.label; - let top = Point::new( - label.position.x * self.0.zoom + self.0.pan.x, - label.position.y * self.0.zoom + self.0.pan.y, - ); - frame.fill_rectangle( - top, - iced::Size::new( - label.size.width * self.0.zoom, - label.size.height * self.0.zoom, - ), - Color::WHITE, - ); - frame.fill_text(Text { - content: target.name.clone(), - position: top, - size: (16. * self.0.zoom).into(), - color, - ..Default::default() - }); } vec![frame.into_geometry()] } diff --git a/crates/oa-desktop/src/views.rs b/crates/oa-desktop/src/views.rs index f4651b77..3b3f75ce 100644 --- a/crates/oa-desktop/src/views.rs +++ b/crates/oa-desktop/src/views.rs @@ -510,6 +510,31 @@ fn diagram(app: &App) -> View<'_, Message> { tools = tools.push(modes); } if projection == Some(ProjectionKind::Allocation) { + // Reserve this slot even when idle: changing feedback must never resize + // the measured canvas and invalidate a held gesture. Its identity is + // independent of status length, target geometry and the diagram zoom. + let destination = app + .allocation_drag + .feedback + .as_ref() + .map(|feedback| feedback.destination_identity()) + .unwrap_or_else(|| "None · drag work to choose".into()); + tools = tools.push( + container(tooltip( + text(format!("Destination: {}", short(&destination, 28))) + .size(13) + .wrapping(widget::text::Wrapping::None), + container(text(destination).size(13)) + .padding(10) + .max_width(420) + .style(style::card), + tooltip::Position::Top, + )) + .id("allocation-destination") + .height(26) + .width(Fill) + .clip(true), + ); tools = tools.push( row![ action( @@ -561,7 +586,7 @@ fn diagram(app: &App) -> View<'_, Message> { "Right-click for actions · drag to arrange · scroll to zoom" }; // Status is outside the measured canvas. Clipping prevents long text - // from painting over work headers or frozen target labels below it. + // from painting over work headers or the dedicated destination slot. tools = tools.push(container(label(hint)).height(30).width(Fill).clip(true)); } column![container(tools).padding(14), canvas].into()