Keep allocation destinations readable without blocking collapsed header drops

This commit is contained in:
2026-09-12 04:34:02 -07:00
parent 76209cc186
commit 1b3c401a19
7 changed files with 416 additions and 157 deletions
+26 -19
View File
@@ -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<Id>,
}
@@ -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::<Vec<_>>()
.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))?;
@@ -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());
}
}
}
@@ -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");
}