From d61f1f38779ab70a77efcde523ae31c432aefcfd Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Sat, 12 Sep 2026 15:59:17 -0700 Subject: [PATCH] Keep activation borders above message label backplates --- crates/oa-desktop/src/behavior.rs | 47 ++++++---- .../src/behavior/scenario_activation/tests.rs | 93 +++++++++++++++++++ 2 files changed, 124 insertions(+), 16 deletions(-) diff --git a/crates/oa-desktop/src/behavior.rs b/crates/oa-desktop/src/behavior.rs index de440d3e..ea70799e 100644 --- a/crates/oa-desktop/src/behavior.rs +++ b/crates/oa-desktop/src/behavior.rs @@ -1621,17 +1621,9 @@ impl Scene { .iter() .find(|line| { !line.label.is_empty() - && (Rectangle::new( - line.label_at, - Size::new( - (line.label.chars().count() as f32 * 6.8 + 8.).min(225.), - 25., - ), - ) - .contains(point) - || line.points.windows(2).any(|p| { - crate::diagram::distance_to_segment(point, p[0], p[1]) < 1.5 - })) + && line.points.windows(2).any(|p| { + crate::diagram::distance_to_segment(point, p[0], p[1]) < 1.5 + }) }) .map(|line| line.id) }) @@ -1642,6 +1634,22 @@ impl Scene { .find(|b| b.rect.contains(point)) .map(|b| b.call) }) + .or_else(|| { + self.lines + .iter() + .find(|line| { + !line.label.is_empty() + && Rectangle::new( + line.label_at, + Size::new( + (line.label.chars().count() as f32 * 6.8 + 8.).min(225.), + 25., + ), + ) + .contains(point) + }) + .map(|line| line.id) + }) // The generous message tolerance must not steal the exposed // shoulder of another call's execution when the view is zoomed out. .or_else(|| { @@ -1973,6 +1981,18 @@ impl canvas::Program for Scene { .with_width(if guide.id == self.selected { 2.5 } else { 1.2 }), ); } + // Label backplates clear guides, but must not erase execution borders + // or message geometry. Their text remains in the foreground below. + for line in self.lines.iter().filter(|line| !line.label.is_empty()) { + f.fill_rectangle( + line.label_at, + Size::new( + (line.label.chars().count() as f32 * 6.8 + 8.).min(225.), + 22., + ), + Color::WHITE, + ); + } for activation in &self.activations { let path = Path::rectangle(activation.rect.position(), activation.rect.size()); f.fill(&path, Color::from_rgb8(243, 248, 248)); @@ -2054,11 +2074,6 @@ impl canvas::Program for Scene { }), Stroke::default().with_color(color), ); - f.fill_rectangle( - l.label_at, - Size::new((l.label.chars().count() as f32 * 6.8 + 8.).min(225.), 22.), - Color::WHITE, - ); f.fill_text(Text { content: short(&l.label, 32), position: l.label_at, diff --git a/crates/oa-desktop/src/behavior/scenario_activation/tests.rs b/crates/oa-desktop/src/behavior/scenario_activation/tests.rs index abf0cfbd..f4ed75c3 100644 --- a/crates/oa-desktop/src/behavior/scenario_activation/tests.rs +++ b/crates/oa-desktop/src/behavior/scenario_activation/tests.rs @@ -118,6 +118,99 @@ fn scene(f: &Fixture) -> Scene { fn bar(scene: &Scene, call: Id) -> &Segment { scene.activations.iter().find(|a| a.call == call).unwrap() } + +fn long_labels(f: &mut Fixture) { + for occurrence in [f.call, f.inner, f.inner_reply, f.reply] { + f.project.elements.get_mut(&occurrence).unwrap().name = + "Pressure verification with a descriptive operation label".into(); + } +} + +#[test] +fn activation_shoulders_under_label_backplates_select_their_call() { + for layer in [Layer::Oa, Layer::Sa] { + let mut f = fixture(layer, true); + long_labels(&mut f); + let scene = scene(&f); + let outer = bar(&scene, f.call); + for occurrence in [f.inner, f.inner_reply, f.reply] { + let message = line(&scene, occurrence); + let shoulder = Point::new(outer.rect.x + 1., message.label_at.y + 11.); + assert!(outer.rect.contains(shoulder)); + assert!(Rectangle::new(message.label_at, Size::new(225., 22.)).contains(shoulder)); + assert!(message.label.chars().count() > 32); + assert!(message.points.windows(2).all(|p| { + crate::diagram::distance_to_segment(shoulder, p[0], p[1]) > 8. / scene.scale + })); + assert_eq!(scene.hit(shoulder), Some(f.call)); + assert_eq!( + scene.hit(message.label_at + iced::Vector::new(4., 4.)), + Some(occurrence), + "Label text outside activations remains selectable" + ); + } + for occurrence in [f.call, f.inner, f.inner_reply, f.reply] { + let message = line(&scene, occurrence); + for endpoint in [message.points[0], *message.points.last().unwrap()] { + assert_eq!(scene.hit(endpoint), Some(occurrence)); + } + } + } +} + +#[tokio::test] +async fn long_message_backplates_do_not_erase_selected_activation_border_pixels() { + use iced::advanced::renderer::Headless; + use iced_runtime::user_interface::{Cache, UserInterface}; + + let mut mounted = crate::behavior::canvas_workspace::tests::Mounted::unshown().await; + let mut f = fixture(Layer::Oa, true); + long_labels(&mut f); + let mut scene = scene(&f); + scene.selected = f.call; + let border_x = bar(&scene, f.call).rect.x; + let label_spans: Vec<_> = [f.inner, f.inner_reply, f.reply] + .into_iter() + .map(|id| { + let message = line(&scene, id); + assert!( + Rectangle::new(message.label_at, Size::new(225., 22.)) + .contains(Point::new(border_x, message.label_at.y + 11.)) + ); + (message.label_at.y + 3.) as usize..(message.label_at.y + 20.) as usize + }) + .collect(); + let dimensions = Size::new(800., 700.); + let content: Element<'_, Message> = canvas::Canvas::new(scene) + .width(dimensions.width) + .height(dimensions.height) + .into(); + let mut ui = UserInterface::build(content, dimensions, Cache::new(), &mut mounted.renderer); + ui.draw( + &mut mounted.renderer, + &style::theme(), + &iced::advanced::renderer::Style { + text_color: style::INK, + }, + mouse::Cursor::Unavailable, + ); + let pixels = Headless::screenshot(&mut mounted.renderer, Size::new(800, 700), 1., Color::WHITE); + let expected = [style::TEAL.r, style::TEAL.g, style::TEAL.b].map(|v| v * 255.); + for span in label_spans { + for y in span { + assert!( + ((border_x as usize - 2)..=(border_x as usize + 2)).any(|x| { + let offset = (y * 800 + x) * 4; + pixels[offset..offset + 3] + .iter() + .zip(expected) + .all(|(actual, target)| (*actual as f32 - target).abs() < 30.) + }), + "Selected activation border was erased at y={y}" + ); + } + } +} fn line(scene: &Scene, occurrence: Id) -> &super::super::Line { scene.lines.iter().find(|a| a.id == occurrence).unwrap() }