Files
ARCADIA-Studio/crates/oa-desktop/src/diagram/port_labels.rs
T

385 lines
13 KiB
Rust

//! Passive renderer layer for port identity feedback. Iced batches every text
//! primitive after meshes within one GPU layer, even across Canvas frames.
//! A second stacked canvas places the complete tooltip above diagram text.
use super::*;
pub struct PortLabels<'a>(pub DiagramCanvas<'a>);
#[derive(Default)]
pub struct State {
view: RefCell<Option<(Context, ProjectedView)>>,
context: Option<Context>,
held_buttons: u8,
}
impl canvas::Program<Message> for PortLabels<'_> {
type State = State;
fn update(
&self,
state: &mut State,
event: &canvas::Event,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Option<Action<Message>> {
if state.context != Some(self.0.context) {
state.context = Some(self.0.context);
state.held_buttons = 0;
}
let button_mask = |button| match button {
mouse::Button::Left => 1,
mouse::Button::Middle => 2,
_ => 0,
};
match event {
canvas::Event::Mouse(mouse::Event::ButtonPressed(button)) if cursor.is_over(bounds) => {
state.held_buttons |= button_mask(*button)
}
canvas::Event::Mouse(mouse::Event::ButtonReleased(button)) => {
state.held_buttons &= !button_mask(*button)
}
canvas::Event::Window(iced::window::Event::Unfocused) => state.held_buttons = 0,
canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed { key, .. })
if *key == iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape) =>
{
state.held_buttons = 0
}
_ => {}
}
// Never capture input: every event still reaches the interactive canvas.
matches!(event, canvas::Event::Mouse(_)).then(Action::request_redraw)
}
fn draw(
&self,
state: &State,
renderer: &Renderer,
_: &Theme,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Vec<canvas::Geometry> {
if self.0.work_preview().is_some() {
return vec![]; // The candidate canvas already paints its own ports.
}
if state.context == Some(self.0.context) && state.held_buttons != 0 {
return vec![];
}
let canvas = &self.0;
let mut cached = state.view.borrow_mut();
if cached
.as_ref()
.is_none_or(|(context, _)| *context != canvas.context)
{
*cached = Some((
canvas.context,
canvas.projected(&canvas.project.diagrams[&canvas.diagram]),
));
}
let view = &cached.as_ref().unwrap().1;
let Some(port) = cursor
.position_in(bounds)
.and_then(|p| canvas.hit_port(view, p))
.or_else(|| view.ports.iter().find(|p| p.element == canvas.selected))
else {
return vec![];
};
let center = point(port.position);
let mut port_label = Frame::new(renderer, bounds.size());
let name = short(canvas.project.named(port.element), 40);
let owner = canvas.project.elements[&port.element]
.parent
.map(|id| short(canvas.project.named(id), 40))
.unwrap_or_default();
let alias = &canvas.project.elements[&port.element].alias;
let alias = if alias.chars().count() > 48 {
format!(
"@{}…{}",
alias.chars().take(22).collect::<String>(),
alias
.chars()
.skip(alias.chars().count() - 24)
.collect::<String>()
)
} else {
format!("@{alias}")
};
let width = ((name
.chars()
.count()
.max(owner.chars().count())
.max(alias.chars().count()) as f32
* 7.)
+ 16.)
.min((bounds.width - 8.).max(0.));
let height = 60.;
let anchor = Point::new(
center.x * canvas.zoom + canvas.pan.x + 12.,
center.y * canvas.zoom + canvas.pan.y + 12.,
);
let origin = Point::new(
anchor.x.clamp(4., (bounds.width - width - 4.).max(4.)),
anchor.y.clamp(4., (bounds.height - height - 4.).max(4.)),
);
let background = Path::rounded_rectangle(origin, Size::new(width, height), 4.into());
port_label.fill(&background, Color::WHITE);
port_label.stroke(&background, Stroke::default().with_color(style::LINE));
port_label.fill_text(Text {
content: name,
position: origin + Vector::new(8., 4.),
color: style::TEAL,
size: 12.into(),
..Default::default()
});
port_label.fill_text(Text {
content: owner,
position: origin + Vector::new(8., 22.),
color: style::MUTED,
size: 11.into(),
..Default::default()
});
port_label.fill_text(Text {
content: alias,
position: origin + Vector::new(8., 40.),
color: style::MUTED,
size: 11.into(),
..Default::default()
});
vec![port_label.into_geometry()]
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{app::App, model_menu};
use canvas::Program;
fn fixture() -> (App, Id) {
let mut project = Project::empty();
let owner = project.add(Kind::Function, "Send report", 100., 150.);
let port = Id::new_v4();
project.elements.insert(
port,
make_port(
&project,
port,
owner,
"Report output",
PortContract::FunctionOutput {
outgoing_items: vec![],
},
)
.unwrap(),
);
(App::with_project(project), port)
}
fn program(app: &App) -> PortLabels<'_> {
PortLabels(DiagramCanvas {
project: app.project(),
view_index: None,
diagram: app.active_diagram.unwrap(),
selected: app.selected,
navigation_focus: None,
zoom: 1.,
pan: Vector::ZERO,
context: app.route_context().unwrap(),
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
allocation: None,
})
}
fn bounds() -> Rectangle {
Rectangle::new(Point::new(120., 80.), Size::new(900., 600.))
}
// Stack visits this canvas first. Every event must remain available to the
// interactive child, and this layer must never levitate its cursor.
fn passive(labels: &PortLabels<'_>, state: &mut State, event: canvas::Event, at: Point) {
let cursor = mouse::Cursor::Available(at);
if let Some(action) = labels.update(state, &event, bounds(), cursor) {
let (message, _, status) = action.into_inner();
assert!(message.is_none());
assert_eq!(status, iced::event::Status::Ignored);
}
assert_eq!(
labels.mouse_interaction(state, bounds(), cursor),
mouse::Interaction::None
);
}
fn mouse(labels: &PortLabels<'_>, state: &mut State, event: mouse::Event) {
passive(
labels,
state,
canvas::Event::Mouse(event),
bounds().center(),
);
}
#[test]
fn leaving_and_reentering_keeps_suppression_until_each_held_button_releases() {
let (app, _) = fixture();
let labels = program(&app);
let mut state = State::default();
mouse(
&labels,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
);
mouse(
&labels,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Middle),
);
mouse(&labels, &mut state, mouse::Event::CursorLeft);
mouse(&labels, &mut state, mouse::Event::CursorEntered);
mouse(
&labels,
&mut state,
mouse::Event::CursorMoved {
position: bounds().center(),
},
);
assert_ne!(
state.held_buttons, 0,
"Preview geometry remains active across reentry"
);
mouse(
&labels,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Right),
);
mouse(
&labels,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
);
assert_ne!(state.held_buttons, 0, "Middle-button pan is still held");
mouse(
&labels,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Middle),
);
assert_eq!(state.held_buttons, 0);
}
#[test]
fn menu_epoch_clears_suppression_when_the_overlay_consumes_release() {
let (mut app, port) = fixture();
let before = app.document.bundle().clone();
let mut state = State::default();
mouse(
&program(&app),
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
);
let old_context = state.context;
let _ = app.update(model_menu::open(port, bounds().center()));
assert!(app.model_menu.is_some());
assert_ne!(old_context, app.route_context());
// Left release and dismissal input are captured by the root popup.
let _ = app.update(Message::Menu(model_menu::Event::Close));
passive(
&program(&app),
&mut state,
canvas::Event::Window(iced::window::Event::RedrawRequested(
std::time::Instant::now(),
)),
bounds().center(),
);
assert_eq!(state.held_buttons, 0);
assert_eq!(state.context, app.route_context());
assert_eq!(app.document.bundle(), &before);
}
#[test]
fn outside_presses_do_not_suppress_and_cancel_or_focus_loss_clear_held_buttons() {
let (app, _) = fixture();
let labels = program(&app);
let mut state = State::default();
passive(
&labels,
&mut state,
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
Point::ORIGIN,
);
assert_eq!(state.held_buttons, 0);
for event in [
canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
modified_key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
physical_key: iced::keyboard::key::Physical::Code(
iced::keyboard::key::Code::Escape,
),
location: iced::keyboard::Location::Standard,
modifiers: iced::keyboard::Modifiers::empty(),
text: None,
repeat: false,
}),
canvas::Event::Window(iced::window::Event::Unfocused),
] {
mouse(
&labels,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
);
mouse(
&labels,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Middle),
);
passive(&labels, &mut state, event, bounds().center());
assert_eq!(state.held_buttons, 0);
}
}
#[test]
fn passive_layer_leaves_port_drag_selection_and_commit_with_the_interactive_canvas() {
let (app, port) = fixture();
let labels = program(&app);
let view = project_view(app.project(), labels.0.diagram);
let projected = view.ports.iter().find(|p| p.element == port).unwrap();
let owner = view.nodes.iter().find(|n| n.id == projected.owner).unwrap();
let screen = |p: Position| point(p) + Vector::new(bounds().x, bounds().y);
let start = screen(projected.position);
let end = screen(projection::port_anchor_position(
owner,
PortAnchorSide::Top,
0.3,
));
let mut state = State::default();
let mut interactive = super::super::State::default();
let mut messages = Vec::new();
for event in [
mouse::Event::CursorMoved { position: start },
mouse::Event::ButtonPressed(mouse::Button::Left),
mouse::Event::CursorMoved { position: end },
mouse::Event::ButtonReleased(mouse::Button::Left),
] {
let event = canvas::Event::Mouse(event);
passive(&labels, &mut state, event.clone(), end);
if let Some(action) = labels.0.update(
&mut interactive,
&event,
bounds(),
mouse::Cursor::Available(end),
) {
let (message, _, _) = action.into_inner();
messages.extend(message);
}
}
assert!(matches!(messages.first(), Some(Message::Select(id)) if *id == port));
assert!(
matches!(messages.last(), Some(Message::PortAnchor(crate::port_edit::Event::Commit(_, _, anchor))) if anchor.port == port && anchor.side == PortAnchorSide::Top)
);
assert_eq!(messages.len(), 2);
assert_eq!(state.held_buttons, 0);
}
}