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
+6 -35
View File
@@ -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,
+14 -68
View File
@@ -53,16 +53,7 @@ fn presentation(p: &Project, d: Id, target: Option<Id>) -> 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] {
+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");
}
+2 -22
View File
@@ -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<Message> for WorkFeedback<'_> {
type State = ();
@@ -179,26 +179,6 @@ impl canvas::Program<Message> 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()]
}
+26 -1
View File
@@ -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()