Keep long destination names inspectable without granting stale drop authority

This commit is contained in:
2026-09-12 04:41:50 -07:00
parent 1b3c401a19
commit 5ce798b713
6 changed files with 238 additions and 6 deletions
@@ -252,6 +252,7 @@ pub struct Preview {
}
struct Active {
press: Arc<Press>,
inspection_targets: Vec<usize>,
ids: BTreeMap<Id, Id>,
cache: Vec<(
plan::Destination,
@@ -261,10 +262,34 @@ struct Active {
pub struct Feedback {
pub press: Arc<Press>,
pub targets: Vec<usize>,
/// Last captured target names for inspection only; never resolver input.
pub inspection_targets: Vec<usize>,
pub text: String,
pub refused: bool,
}
impl Feedback {
pub fn inspection_identity(&self) -> String {
if self.refused && self.targets.is_empty() && !self.inspection_targets.is_empty() {
let names = self
.inspection_targets
.iter()
.map(|index| self.press.frozen.targets[*index].name.as_str())
.collect::<Vec<_>>()
.join(" / ");
format!(
"{}\nLast target{}: {}\nReturn to the canvas to choose a destination; releasing here cancels the drag.",
self.destination_identity(),
if self.inspection_targets.len() > 1 {
"s (ambiguous)"
} else {
""
},
names
)
} else {
self.destination_identity()
}
}
/// Captured accepted names, independent of clipped status/refusal prose.
pub fn destination_identity(&self) -> String {
match self.targets.as_slice() {
@@ -481,6 +506,7 @@ impl App {
self.allocation_drag.feedback = None;
self.allocation_drag.active = Some(Active {
press,
inspection_targets: Vec::new(),
ids,
cache: Vec::new(),
});
@@ -528,6 +554,13 @@ impl App {
let press = active.press.clone();
let moved = pointer.is_some_and(|p| p.distance(press.start) > 3.);
let hit = press.frozen.resolve(stamp, pointer, press.arrange);
if moved {
match &hit {
Hit::Destination(_, Some(target)) => active.inspection_targets = vec![*target],
Hit::Ambiguous(targets) => active.inspection_targets = targets.clone(),
_ => {}
}
}
let mut targets = Vec::new();
let mut preview = None;
let result: Result<String, String> = match hit {
@@ -591,11 +624,13 @@ impl App {
if release {
self.allocation_drag.retire();
} else {
let inspection_targets = active.inspection_targets.clone();
self.allocation_drag.active = Some(active);
self.allocation_drag.preview = preview;
self.allocation_drag.feedback = Some(Feedback {
press,
targets,
inspection_targets,
text,
refused,
});
@@ -370,7 +370,10 @@ async fn actual_app_tree_exact_incoming_drag_keeps_destination_visible_and_canva
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.));
// Use the middle of the snap cell. Fractional App-layout origins
// can perturb an exact half-grid tie by one floating-point ULP.
// The packet/planner cases retain the original y140 counterexample.
let to = at(&mut mounted, &f, xy(160., 145.));
assert!(
canvas.contains(from) && canvas.contains(to),
"{canvas:?} {from:?} {to:?}"
@@ -397,7 +400,8 @@ async fn actual_app_tree_exact_incoming_drag_keeps_destination_visible_and_canva
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.)
xy(120., 110.),
"{layer:?}, minimum={minimum}, zoom={zoom}, pan={pan:?}, canvas={canvas:?}"
);
assert!(!preview.placement.diagram.nodes[&target_id].collapsed);
let selected = preview
@@ -432,6 +436,18 @@ async fn actual_app_tree_exact_incoming_drag_keeps_destination_visible_and_canva
assert_eq!(canvas_bounds(&mut mounted, &f.app), canvas);
assert_eq!(destination_bounds(&mut mounted, &f.app), slot);
let active = mounted.pixels(&f.app);
if minimum {
record_render_evidence(
&format!(
"{layer:?}-minimum-{}",
if pan.y < 0. { "top" } else { "normal" }
),
&active,
&f.app,
slot,
canvas,
);
}
let width = f.app.window_size.width as usize;
assert_ne!(
canvas_pixels(&idle, width, slot),
@@ -504,3 +520,173 @@ async fn actual_app_tree_exact_incoming_drag_keeps_destination_visible_and_canva
}
}
}
/// Optional reproducible visual evidence; normal test runs create no artifacts.
fn record_render_evidence(
name: &str,
pixels: &[u8],
app: &App,
slot: Rectangle,
canvas: Rectangle,
) {
let Some(path) = std::env::var_os("ARCADIA_RENDER_EVIDENCE_DIR") else {
return;
};
let path = std::path::PathBuf::from(path);
std::fs::create_dir_all(&path).unwrap();
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path.join(format!("{name}.rgba")))
.unwrap();
std::io::Write::write_all(&mut file, pixels).unwrap();
let metadata = serde_json::json!({
"width": app.window_size.width as u32, "height": app.window_size.height as u32,
"destination": [slot.x, slot.y, slot.width, slot.height],
"canvas": [canvas.x, canvas.y, canvas.width, canvas.height],
"identity": app.allocation_drag.feedback.as_ref().map(|f| f.destination_identity()),
});
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path.join(format!("{name}.json")))
.unwrap();
std::io::Write::write_all(&mut file, &serde_json::to_vec_pretty(&metadata).unwrap()).unwrap();
}
#[tokio::test]
async fn actual_toolbar_hover_inspects_last_full_name_without_authorizing_an_outside_drop() {
for layer in [Layer::Oa, Layer::Sa] {
let mut f = fixture(layer);
let name = "Receiving subsystem with a long exact identity beyond the compact label";
let mut project = f.app.project().clone();
project.elements.get_mut(&f.a).unwrap().name = name.into();
project
.diagrams
.get_mut(&f.diagram)
.unwrap()
.nodes
.get_mut(&f.node)
.unwrap()
.position
.y = 210.;
replace(&mut f, project);
f.app.layer = layer;
f.app.window_size = iced::Size::new(1100., 700.);
let _ = f.app.update(Message::Select(f.work));
let mut mounted = Mounted::new(&mut f.app).await;
f.app.zoom = 1.;
f.app.pan = Vector::ZERO;
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);
let from = at(&mut mounted, &f, xy(140., 245.));
let to = at(&mut mounted, &f, xy(160., 145.));
let before = f.app.document.bundle().clone();
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;
assert_eq!(
f.app
.allocation_drag
.feedback
.as_ref()
.unwrap()
.destination_identity(),
name
);
assert!(f.app.allocation_drag.preview.is_some());
let hover = Point::new(slot.x + 45., slot.y + 8.);
mounted
.batch(
&mut f.app,
&[Input::Mouse(mouse::Event::CursorMoved { position: hover })],
mouse::Cursor::Available(hover),
)
.await;
let feedback = f.app.allocation_drag.feedback.as_ref().unwrap();
assert!(feedback.refused);
assert!(feedback.targets.is_empty());
assert_eq!(feedback.destination_identity(), "No eligible destination");
assert!(
feedback
.inspection_identity()
.contains(&format!("Last target: {name}"))
);
assert!(f.app.allocation_drag.preview.is_none());
assert!(f.app.allocation_drag.active.is_some());
let inspected = mounted.pixels_at(&f.app, mouse::Cursor::Available(hover));
let tooltip = mounted
.geometry(&f.app)
.bounds("allocation-destination-tooltip");
assert!(tooltip.width > 0. && tooltip.height > 26.);
assert!(tooltip.y >= 0. && tooltip.x >= 0.);
assert!(tooltip.x + tooltip.width <= f.app.window_size.width);
assert_eq!(canvas_bounds(&mut mounted, &f.app), canvas);
record_render_evidence(
&format!("{layer:?}-name-inspection"),
&inspected,
&f.app,
slot,
canvas,
);
assert_eq!(f.app.document.bundle(), &before);
assert!(!f.app.document.can_undo());
// Inspection grants no mutation authority. Releasing over it cancels.
mounted
.batch(
&mut f.app,
&[Input::Mouse(mouse::Event::ButtonReleased(
mouse::Button::Left,
))],
mouse::Cursor::Available(hover),
)
.await;
assert!(f.app.allocation_drag.active.is_none());
assert!(f.app.allocation_drag.feedback.is_none());
assert_eq!(f.app.document.bundle(), &before);
assert!(!f.app.document.can_undo());
// A fresh gesture cannot inherit the previous inspection name.
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: hover }),
],
mouse::Cursor::Available(hover),
)
.await;
let feedback = f.app.allocation_drag.feedback.as_ref().unwrap();
assert!(feedback.inspection_targets.is_empty());
assert!(!feedback.inspection_identity().contains(name));
mounted
.batch(
&mut f.app,
&[
Input::Mouse(mouse::Event::CursorMoved { position: to }),
Input::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
],
mouse::Cursor::Available(to),
)
.await;
assert_eq!(
validation::effective_performer(f.app.project(), f.work),
Some(f.a)
);
assert_eq!(f.app.document.revision(), 1);
let _ = f.app.update(Message::Undo);
assert_eq!(f.app.document.bundle(), &before);
}
}
@@ -1179,6 +1179,7 @@ fn destination_identity_does_not_describe_blocked_or_outside_viewport_as_unalloc
let mut feedback = Feedback {
press: p,
targets: vec![],
inspection_targets: vec![],
text: "A card blocks this drop".into(),
refused: true,
};