Keep allocation destinations readable without blocking collapsed header drops
This commit is contained in:
@@ -416,19 +416,11 @@ impl Rect {
|
|||||||
&& other.position.x + other.size.width <= self.position.x + self.size.width
|
&& other.position.x + other.size.width <= self.position.x + self.size.width
|
||||||
&& other.position.y + other.size.height <= self.position.y + self.size.height
|
&& 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)]
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
pub struct TargetPresentation {
|
pub struct TargetPresentation {
|
||||||
pub placement: Id,
|
pub placement: Id,
|
||||||
/// Exact frozen painted label bounds, supplied by the renderer/resolver.
|
|
||||||
pub label: Rect,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
@@ -526,29 +518,18 @@ pub fn place(
|
|||||||
let mut unfolded = None;
|
let mut unfolded = None;
|
||||||
if let Destination::Performer(performer) = plan.destination {
|
if let Destination::Performer(performer) = plan.destination {
|
||||||
let target = presentation.target.ok_or_else(|| {
|
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
|
let node = diagram
|
||||||
.nodes
|
.nodes
|
||||||
.get_mut(&target.placement)
|
.get_mut(&target.placement)
|
||||||
.ok_or(Refusal::StaleScope)?;
|
.ok_or(Refusal::StaleScope)?;
|
||||||
let frozen_header = plan
|
let visible_target = plan
|
||||||
.before_visible
|
.before_visible
|
||||||
.nodes
|
.nodes
|
||||||
.iter()
|
.iter()
|
||||||
.find(|n| n.id == target.placement)
|
.any(|n| n.id == target.placement && n.element == performer);
|
||||||
.map(|n| Rect {
|
if node.element != performer || !visible_target {
|
||||||
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)
|
|
||||||
{
|
|
||||||
return Err(Refusal::LayoutRequired(
|
return Err(Refusal::LayoutRequired(
|
||||||
"Make room or adjust the view, then drag again.".into(),
|
"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(),
|
"Make room or adjust the view, then drag again.".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
// A performer may legitimately enclose the work, but its frozen feedback
|
// Destination identity lives in a fixed toolbar slot. Frozen hit geometry
|
||||||
// label is painted above the candidate. Check their joint readability after
|
// and its outline never introduce an opaque label over this work header.
|
||||||
// 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(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(PlacementPlan {
|
Ok(PlacementPlan {
|
||||||
diagram,
|
diagram,
|
||||||
view,
|
view,
|
||||||
|
|||||||
@@ -53,16 +53,7 @@ fn presentation(p: &Project, d: Id, target: Option<Id>) -> PresentationContext {
|
|||||||
let target = target.map(|element| {
|
let target = target.map(|element| {
|
||||||
let view = projection::project_view(p, d);
|
let view = projection::project_view(p, d);
|
||||||
let node = view.nodes.iter().find(|n| n.element == element).unwrap();
|
let node = view.nodes.iter().find(|n| n.element == element).unwrap();
|
||||||
TargetPresentation {
|
TargetPresentation { placement: node.id }
|
||||||
placement: node.id,
|
|
||||||
label: Rect {
|
|
||||||
position: node.position,
|
|
||||||
size: Size {
|
|
||||||
width: node.size.width.min(224.),
|
|
||||||
height: 62.,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
PresentationContext {
|
PresentationContext {
|
||||||
viewport: Rect {
|
viewport: Rect {
|
||||||
@@ -1008,7 +999,7 @@ fn collapsed_target_unfold_is_explicit_and_no_other_fold_changes() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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 f = fixture(Layer::Oa);
|
||||||
let plan = semantic(&f, Destination::Performer(f.a));
|
let plan = semantic(&f, Destination::Performer(f.a));
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
@@ -1016,13 +1007,13 @@ fn target_presentation_must_name_the_destination_and_its_visible_frozen_header()
|
|||||||
Err(Refusal::LayoutRequired(_))
|
Err(Refusal::LayoutRequired(_))
|
||||||
));
|
));
|
||||||
let mut context = presentation(&f.p, f.d, Some(f.a));
|
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!(
|
assert!(matches!(
|
||||||
place(&plan, xy(150., 200.), context),
|
place(&plan, xy(150., 200.), context),
|
||||||
Err(Refusal::LayoutRequired(_))
|
Err(Refusal::LayoutRequired(_))
|
||||||
));
|
));
|
||||||
let mut clipped = presentation(&f.p, f.d, Some(f.a));
|
let mut clipped = presentation(&f.p, f.d, Some(f.a));
|
||||||
clipped.viewport.position.y = 180.;
|
clipped.viewport.position.y = 220.;
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
place(&plan, xy(150., 200.), clipped),
|
place(&plan, xy(150., 200.), clipped),
|
||||||
Err(Refusal::LayoutRequired(_))
|
Err(Refusal::LayoutRequired(_))
|
||||||
@@ -1030,78 +1021,34 @@ fn target_presentation_must_name_the_destination_and_its_visible_frozen_header()
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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] {
|
for layer in [Layer::Oa, Layer::Sa] {
|
||||||
let mut f = fixture(layer);
|
let mut f = fixture(layer);
|
||||||
f.p.elements.get_mut(&f.a).unwrap().name = "Receiver".into();
|
f.p.elements.get_mut(&f.a).unwrap().name = "Receiver".into();
|
||||||
let plan = semantic(&f, Destination::Performer(f.a));
|
let plan = semantic(&f, Destination::Performer(f.a));
|
||||||
let mut context = presentation(&f.p, f.d, Some(f.a));
|
let 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 before = plan.project().clone();
|
let before = plan.project().clone();
|
||||||
assert!(matches!(
|
let placed = place(&plan, xy(120., 105.), context).unwrap();
|
||||||
place(&plan, xy(120., 105.), context),
|
assert_eq!(placed.diagram.nodes[&f.n].position, xy(120., 110.));
|
||||||
Err(Refusal::LayoutRequired(message)) if message.contains("Aim below or beside")
|
|
||||||
));
|
|
||||||
assert_eq!(plan.project(), &before);
|
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());
|
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!(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.elements, f.p.elements);
|
||||||
assert_eq!(after.diagrams[&f.second], f.p.diagrams[&f.second]);
|
assert_eq!(after.diagrams[&f.second], f.p.diagrams[&f.second]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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 f = fixture(Layer::Oa);
|
||||||
let plan = semantic(&f, Destination::Performer(f.a));
|
let plan = semantic(&f, Destination::Performer(f.a));
|
||||||
let mut context = presentation(&f.p, f.d, Some(f.a));
|
let mut context = presentation(&f.p, f.d, Some(f.a));
|
||||||
context.target.as_mut().unwrap().label = Rect {
|
context.viewport.position.y = 100.;
|
||||||
position: xy(110., 131.),
|
|
||||||
size: Size {
|
|
||||||
width: 90.,
|
|
||||||
height: 19.,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
for (position, allowed) in [
|
for (position, allowed) in [
|
||||||
(xy(190., 110.), false),
|
(xy(120., 90.), false),
|
||||||
(xy(200., 110.), true),
|
(xy(120., 100.), true),
|
||||||
(xy(210., 110.), true),
|
(xy(120., 110.), true),
|
||||||
(xy(120., 140.), false),
|
|
||||||
(xy(120., 150.), true),
|
|
||||||
(xy(120., 160.), true),
|
|
||||||
] {
|
] {
|
||||||
let result = place(&plan, position, context);
|
let result = place(&plan, position, context);
|
||||||
assert_eq!(result.is_ok(), allowed, "{position:?}: {result:?}");
|
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]
|
#[test]
|
||||||
fn same_performer_readability_preserves_snapped_noop_and_allocation_identity() {
|
fn same_performer_readability_preserves_snapped_noop_and_allocation_identity() {
|
||||||
for layer in [Layer::Oa, Layer::Sa] {
|
for layer in [Layer::Oa, Layer::Sa] {
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ pub struct Target {
|
|||||||
pub element: Id,
|
pub element: Id,
|
||||||
pub placement: Id,
|
pub placement: Id,
|
||||||
pub region: Rectangle,
|
pub region: Rectangle,
|
||||||
pub label: plan::Rect,
|
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub ancestors: Vec<Id>,
|
pub ancestors: Vec<Id>,
|
||||||
}
|
}
|
||||||
@@ -95,22 +94,7 @@ impl Frozen {
|
|||||||
ancestors.push(id);
|
ancestors.push(id);
|
||||||
parent = project.elements.get(&id).and_then(|e| e.parent);
|
parent = project.elements.get(&id).and_then(|e| e.parent);
|
||||||
}
|
}
|
||||||
// This is also the exact frozen label painted above the ghost.
|
let name = project.named(e.id).to_owned();
|
||||||
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 collapsed = scope.diagram.nodes[&n.id].collapsed;
|
let collapsed = scope.diagram.nodes[&n.id].collapsed;
|
||||||
targets.push(Target {
|
targets.push(Target {
|
||||||
element: e.id,
|
element: e.id,
|
||||||
@@ -123,7 +107,6 @@ impl Frozen {
|
|||||||
} else {
|
} else {
|
||||||
region
|
region
|
||||||
},
|
},
|
||||||
label,
|
|
||||||
name,
|
name,
|
||||||
ancestors,
|
ancestors,
|
||||||
});
|
});
|
||||||
@@ -281,6 +264,30 @@ pub struct Feedback {
|
|||||||
pub text: String,
|
pub text: String,
|
||||||
pub refused: bool,
|
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)]
|
#[derive(Default)]
|
||||||
pub struct State {
|
pub struct State {
|
||||||
pub epoch: u64,
|
pub epoch: u64,
|
||||||
@@ -552,7 +559,7 @@ impl App {
|
|||||||
viewport: stamp.viewport(), zoom: stamp.zoom,
|
viewport: stamp.viewport(), zoom: stamp.zoom,
|
||||||
target: target.map(|i| {
|
target: target.map(|i| {
|
||||||
let t = &press.frozen.targets[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))?;
|
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");
|
.expect("active frozen destination");
|
||||||
assert!(!feedback.refused, "{}", feedback.text);
|
assert!(!feedback.refused, "{}", feedback.text);
|
||||||
let target = &feedback.press.frozen.targets[feedback.targets[0]];
|
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 canvas = canvas_bounds(&mut mounted, &f.app);
|
||||||
let first = mounted.pixels(&f.app);
|
let first = mounted.pixels(&f.app);
|
||||||
f.app.allocation_drag.feedback.as_mut().unwrap().text =
|
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!(
|
assert_eq!(
|
||||||
canvas_pixels(&first, f.app.window_size.width as usize, canvas),
|
canvas_pixels(&first, f.app.window_size.width as usize, canvas),
|
||||||
canvas_pixels(&second, 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]
|
#[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] {
|
for zoom in [0.75, 1.25] {
|
||||||
let mut f = fixture(Layer::Oa);
|
let mut f = fixture(Layer::Oa);
|
||||||
let mut project = f.app.project().clone();
|
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();
|
let feedback = f.app.allocation_drag.feedback.as_ref().unwrap();
|
||||||
assert!(!feedback.refused, "{}", feedback.text);
|
assert!(!feedback.refused, "{}", feedback.text);
|
||||||
let target = &feedback.press.frozen.targets[feedback.targets[0]];
|
let target = &feedback.press.frozen.targets[feedback.targets[0]];
|
||||||
let top = screen(target.label.position).y - bounds().y;
|
assert!(screen(xy(target.region.x, target.region.y)).y < bounds().y);
|
||||||
assert!(
|
assert_eq!(feedback.destination_identity(), "A");
|
||||||
top > 0. && top < 14.,
|
|
||||||
"label starts in the formerly obscured band: {top}"
|
|
||||||
);
|
|
||||||
assert!(f.app.allocation_drag.preview.is_some());
|
assert!(f.app.allocation_drag.preview.is_some());
|
||||||
let _ = f.app.update(release);
|
let _ = f.app.update(release);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -955,7 +952,8 @@ fn toolbar_status_leaves_transformed_top_edge_frozen_target_label_readable() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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 layer in [Layer::Oa, Layer::Sa] {
|
||||||
for (zoom, pan) in [
|
for (zoom, pan) in [
|
||||||
(1., Vector::ZERO),
|
(1., Vector::ZERO),
|
||||||
@@ -972,6 +970,7 @@ fn oa_sa_label_overlap_refuses_preview_release_and_retries_without_partial_sourc
|
|||||||
"Temporary".into(),
|
"Temporary".into(),
|
||||||
)));
|
)));
|
||||||
let _ = f.app.update(Message::Undo);
|
let _ = f.app.update(Message::Undo);
|
||||||
|
let pan = Vector::new(pan.x, pan.y - 100. * zoom);
|
||||||
f.app.zoom = zoom;
|
f.app.zoom = zoom;
|
||||||
f.app.pan = pan;
|
f.app.pan = pan;
|
||||||
f.app.fit_to_view = false;
|
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 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 clear = screen(xy(160., 190.));
|
||||||
let accepted = f.app.project().clone();
|
let accepted = f.app.project().clone();
|
||||||
let source = f.app.document.bundle().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());
|
assert!(f.app.allocation_drag.preview.is_none());
|
||||||
let feedback = f.app.allocation_drag.feedback.as_ref().unwrap();
|
let feedback = f.app.allocation_drag.feedback.as_ref().unwrap();
|
||||||
assert!(feedback.refused, "{}", feedback.text);
|
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!(
|
assert_eq!(
|
||||||
feedback.press.frozen.targets[feedback.targets[0]].element,
|
feedback.press.frozen.targets[feedback.targets[0]].element,
|
||||||
f.a
|
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_undo());
|
||||||
assert!(f.app.document.can_redo());
|
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.
|
// candidate and stable allocation identity; only geometry changes.
|
||||||
let mut events = batch(&f.app, from, overlap);
|
let mut events = batch(&f.app, from, overlap);
|
||||||
events.pop();
|
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");
|
||||||
|
}
|
||||||
|
|||||||
@@ -134,8 +134,8 @@ impl DiagramCanvas<'_> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Separate passive layer: frozen target labels remain above all candidate card
|
/// Passive frozen target outlines. Destination identity is in the toolbar so
|
||||||
/// text. It does not acquire input or use a second independent semantic scene.
|
/// feedback cannot cover candidate text or change the target hit geometry.
|
||||||
pub struct WorkFeedback<'a>(pub DiagramCanvas<'a>);
|
pub struct WorkFeedback<'a>(pub DiagramCanvas<'a>);
|
||||||
impl canvas::Program<Message> for WorkFeedback<'_> {
|
impl canvas::Program<Message> for WorkFeedback<'_> {
|
||||||
type State = ();
|
type State = ();
|
||||||
@@ -179,26 +179,6 @@ impl canvas::Program<Message> for WorkFeedback<'_> {
|
|||||||
),
|
),
|
||||||
Stroke::default().with_color(color).with_width(3.),
|
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()]
|
vec![frame.into_geometry()]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -510,6 +510,31 @@ fn diagram(app: &App) -> View<'_, Message> {
|
|||||||
tools = tools.push(modes);
|
tools = tools.push(modes);
|
||||||
}
|
}
|
||||||
if projection == Some(ProjectionKind::Allocation) {
|
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(
|
tools = tools.push(
|
||||||
row![
|
row![
|
||||||
action(
|
action(
|
||||||
@@ -561,7 +586,7 @@ fn diagram(app: &App) -> View<'_, Message> {
|
|||||||
"Right-click for actions · drag to arrange · scroll to zoom"
|
"Right-click for actions · drag to arrange · scroll to zoom"
|
||||||
};
|
};
|
||||||
// Status is outside the measured canvas. Clipping prevents long text
|
// 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));
|
tools = tools.push(container(label(hint)).height(30).width(Fill).clip(true));
|
||||||
}
|
}
|
||||||
column![container(tools).padding(14), canvas].into()
|
column![container(tools).padding(14), canvas].into()
|
||||||
|
|||||||
Reference in New Issue
Block a user