Merge commit 'ad7feb5' into codex/state-integration

This commit is contained in:
2026-09-06 02:39:53 +03:00
8 changed files with 571 additions and 152 deletions
+7 -77
View File
@@ -28,6 +28,11 @@ mod lifecycle_tests;
#[path = "diagram/port_hit_tests.rs"]
mod port_hit_tests;
#[path = "diagram/port_labels.rs"]
mod port_labels;
pub use port_labels::PortLabels;
#[derive(Clone, Copy)]
pub struct DiagramCanvas<'a> {
pub project: &'a Project,
pub diagram: Id,
@@ -901,7 +906,7 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
renderer: &Renderer,
_: &Theme,
bounds: Rectangle,
cursor: mouse::Cursor,
_: mouse::Cursor,
) -> Vec<canvas::Geometry> {
let mut f = Frame::new(renderer, bounds.size());
f.fill_rectangle(
@@ -922,17 +927,8 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
}
f.translate(self.pan);
f.scale(self.zoom);
// Canvas text is painted after paths within each geometry. A separate
// frame keeps the port tooltip above underlying edge labels. Its text
// stays legible at overview zoom levels and within the canvas bounds.
let mut port_label = Frame::new(renderer, bounds.size());
let scene = self.scene(state);
let view = &scene.view;
let labeled_port = cursor
.position_in(bounds)
.and_then(|p| self.hit_port(view, p))
.or_else(|| view.ports.iter().find(|p| p.element == self.selected))
.map(|p| p.element);
let containers = view
.nodes
.iter()
@@ -1082,68 +1078,6 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
if matches!(glyph, PortGlyph::Unspecified | PortGlyph::Legacy) {
f.fill(&Path::circle(center, 1.5), color);
}
if labeled_port == Some(port.element) {
let name = short(self.project.named(port.element), 40);
let owner = self.project.elements[&port.element]
.parent
.map(|id| short(self.project.named(id), 40))
.unwrap_or_default();
let alias = &self.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 * self.zoom + self.pan.x + 12.,
center.y * self.zoom + self.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()
});
}
}
if let Some(edge) = scene.routed.edges.get(&self.selected)
&& !edge.custom_suspended
@@ -1285,11 +1219,7 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
..Default::default()
});
}
vec![
f.into_geometry(),
port_label.into_geometry(),
overlay.into_geometry(),
]
vec![f.into_geometry(), overlay.into_geometry()]
}
fn mouse_interaction(
&self,
@@ -0,0 +1,373 @@
//! 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 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, project_view(canvas.project, 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(),
diagram: app.active_diagram.unwrap(),
selected: app.selected,
zoom: 1.,
pan: Vector::ZERO,
context: app.route_context().unwrap(),
selected_bend: None,
editable: true,
connection: 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);
}
}
+20 -18
View File
@@ -382,24 +382,26 @@ fn diagram(app: &App) -> View<'_, Message> {
.spacing(10)
.align_y(iced::Center);
let canvas: View<'_, Message> = if let Some(id) = app.active_diagram {
widget::sensor(
widget::canvas(DiagramCanvas {
project: app.project(),
diagram: id,
selected: app.selected,
zoom: app.zoom,
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: app
.selected_bend
.filter(|(edge, _)| *edge == app.selected)
.map(|(_, i)| i),
editable: !app.draft_dirty() && !app.source_dirty(),
connection: app.connection.as_ref(),
})
.width(Fill)
.height(Fill),
)
let program = DiagramCanvas {
project: app.project(),
diagram: id,
selected: app.selected,
zoom: app.zoom,
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: app
.selected_bend
.filter(|(edge, _)| *edge == app.selected)
.map(|(_, i)| i),
editable: !app.draft_dirty() && !app.source_dirty(),
connection: app.connection.as_ref(),
};
widget::sensor(widget::stack![
widget::canvas(program).width(Fill).height(Fill),
widget::canvas(crate::diagram::PortLabels(program))
.width(Fill)
.height(Fill),
])
.on_show(Message::CanvasResized)
.on_resize(Message::CanvasResized)
.into()