Keep long destination names inspectable without granting stale drop authority
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user