Merge commit 'ad7feb5' into codex/state-integration
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user