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,
};
@@ -236,6 +236,9 @@ impl Mounted {
}
}
pub fn pixels(&mut self, app: &App) -> Vec<u8> {
self.pixels_at(app, mouse::Cursor::Unavailable)
}
pub fn pixels_at(&mut self, app: &App, cursor: mouse::Cursor) -> Vec<u8> {
use iced::advanced::renderer::Headless;
let mut ui = UserInterface::build(
app.view(),
@@ -247,7 +250,7 @@ impl Mounted {
let mut messages = vec![];
let _ = ui.update(
&[],
mouse::Cursor::Unavailable,
cursor,
&mut self.renderer,
&mut iced::advanced::clipboard::Null,
&mut messages,
@@ -262,7 +265,7 @@ impl Mounted {
&iced::advanced::renderer::Style {
text_color: crate::style::INK,
},
mouse::Cursor::Unavailable,
cursor,
);
self.cache = ui.into_cache();
Headless::screenshot(
+1 -1
View File
@@ -322,7 +322,7 @@ fn article(topic: Topic) -> Article {
&[
"Finish or cancel property and source edits, then drag an Activity or Function body in Architecture · allocated work. Aim at exposed performer space, including a collapsed performer's header. Read Destination above the canvas and the outlined target before releasing; release outside the canvas or press Escape to cancel.",
"The move preserves work ownership and identities. A uniformly assigned work group moves its leaves together; a group distributed across performers asks you to choose a leaf. Role responsibilities remain explicit: an outward drag that would leave a role-derived performer explains why it cannot remove that responsibility.",
"Make room when work cards would overlap, and move the view or zoom in if the moving work is not readable. The destination name stays above the canvas while the target outline stays fixed during the drag. A refused drop keeps the model and history unchanged. One Undo reverses an accepted assignment and placement together; Save all writes the corresponding source changes.",
"Make room when work cards would overlap, and move the view or zoom in if the moving work is not readable. The destination name stays above the canvas while the target outline stays fixed during the drag. Hover Destination to inspect a long name: outside the canvas, the tooltip explicitly identifies the last target, and releasing cancels. Return to the canvas to choose the drop. A refused drop keeps the model and history unchanged. One Undo reverses an accepted assignment and placement together; Save all writes the corresponding source changes.",
],
),
walkthrough(
+8 -1
View File
@@ -519,12 +519,19 @@ fn diagram(app: &App) -> View<'_, Message> {
.as_ref()
.map(|feedback| feedback.destination_identity())
.unwrap_or_else(|| "None · drag work to choose".into());
let inspection = app
.allocation_drag
.feedback
.as_ref()
.map(|feedback| feedback.inspection_identity())
.unwrap_or_else(|| destination.clone());
tools = tools.push(
container(tooltip(
text(format!("Destination: {}", short(&destination, 28)))
.size(13)
.wrapping(widget::text::Wrapping::None),
container(text(destination).size(13))
container(text(inspection).size(13))
.id("allocation-destination-tooltip")
.padding(10)
.max_width(420)
.style(style::card),