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.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,
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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()]
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user