534 lines
22 KiB
Rust
534 lines
22 KiB
Rust
//! Navigation-only adapter: validates destinations before changing presentation.
|
|
use super::*;
|
|
use crate::behavior::scenario_layout::{Representation, presentation};
|
|
use crate::diagram_usage::{self as usage, Context, Event, Panel, Surface, Target, Usage};
|
|
#[cfg(test)]
|
|
mod tests;
|
|
|
|
impl App {
|
|
pub(crate) fn diagram_usage_message(&self, subject: Id) -> Message {
|
|
Message::DiagramUsage(Event::Open(Context {
|
|
document: self.canvas_document,
|
|
revision: self.document.revision(),
|
|
subject,
|
|
}))
|
|
}
|
|
|
|
fn usage_navigation_available(&self) -> bool {
|
|
!self.busy
|
|
&& !self.source_rename.open()
|
|
&& self.pending.is_none()
|
|
&& self.file_prompt.is_none()
|
|
&& !self.delete_pending
|
|
&& self.workflow.is_none()
|
|
&& !self.hosted.visible
|
|
&& self.architecture.is_none()
|
|
&& !self.reference_input_active()
|
|
&& !self.behavior.has_pending()
|
|
&& !self.draft_dirty()
|
|
&& !self.source_dirty()
|
|
}
|
|
|
|
/// Admit at most one expensive lookup, including one accepted Project clone.
|
|
/// A replacement panel waits for the previous worker's exact completion.
|
|
fn start_diagram_lookup(&mut self) -> Task<Message> {
|
|
let Some(panel) = self.diagram_usages.as_ref() else {
|
|
return Task::none();
|
|
};
|
|
if !panel.loading || self.diagram_lookup_in_flight.is_some() {
|
|
return Task::none();
|
|
}
|
|
let (token, context) = (panel.token, panel.context);
|
|
if context.document != self.canvas_document
|
|
|| context.revision != self.document.revision()
|
|
|| !self.usage_navigation_available()
|
|
{
|
|
let panel = self.diagram_usages.as_mut().unwrap();
|
|
panel.loading = false;
|
|
panel.notice = Some(
|
|
"The model or pending edits changed. Reopen diagram navigation when ready.".into(),
|
|
);
|
|
return Task::none();
|
|
}
|
|
let project = self.project().clone();
|
|
let presentation = self.behavior.topology.usage_snapshot();
|
|
self.diagram_lookup_in_flight = Some(token);
|
|
Task::perform(
|
|
async move {
|
|
let result = tokio::task::spawn_blocking(move || {
|
|
let process = presentation.into_query_state();
|
|
let scenario = behavior::scenario::State::default();
|
|
usage::collect(&project, context.subject, &process, &scenario)
|
|
})
|
|
.await
|
|
.map_err(|error| format!("Diagram lookup failed: {error}. Close and try again."));
|
|
usage::Completion {
|
|
token,
|
|
context,
|
|
result,
|
|
}
|
|
},
|
|
Message::DiagramUsagesReady,
|
|
)
|
|
}
|
|
|
|
fn refresh_diagram_lookup(&mut self, notice: &str) -> Task<Message> {
|
|
let Some(panel) = self.diagram_usages.as_mut() else {
|
|
return Task::none();
|
|
};
|
|
panel.token = Id::new_v4();
|
|
panel.context.revision = self.document.revision();
|
|
panel.usages.clear();
|
|
panel.loading = true;
|
|
panel.page = 0;
|
|
panel.cursor = 0;
|
|
panel.destination = None;
|
|
panel.notice = Some(notice.into());
|
|
self.start_diagram_lookup()
|
|
}
|
|
|
|
/// Must run before modal/input dispatch. A closed or replaced panel still
|
|
/// owns its worker slot until this exact completion arrives.
|
|
pub(super) fn diagram_usages_ready(&mut self, completion: usage::Completion) -> Task<Message> {
|
|
if self.diagram_lookup_in_flight != Some(completion.token) {
|
|
return Task::none();
|
|
}
|
|
self.diagram_lookup_in_flight = None;
|
|
let Some(panel) = self.diagram_usages.as_ref() else {
|
|
return Task::none();
|
|
};
|
|
if panel.context.document != self.canvas_document {
|
|
self.diagram_usages = None;
|
|
return Task::none();
|
|
}
|
|
if panel.context.revision != self.document.revision() {
|
|
return self
|
|
.refresh_diagram_lookup("The model changed. Review the refreshed destinations.");
|
|
}
|
|
if panel.token != completion.token {
|
|
// A newer Open/refresh waited without cloning or spawning a second job.
|
|
return self.start_diagram_lookup();
|
|
}
|
|
let panel = self.diagram_usages.as_mut().unwrap();
|
|
if panel.context != completion.context || !panel.loading {
|
|
panel.loading = false;
|
|
panel.notice = Some(
|
|
"This lookup no longer matches the requested model. Close and try again.".into(),
|
|
);
|
|
return Task::none();
|
|
}
|
|
panel.loading = false;
|
|
match completion.result {
|
|
Ok(usages) => panel.usages = usages,
|
|
Err(error) => panel.notice = Some(error),
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
/// Selection validates one destination only; opening the chooser does the
|
|
/// whole-project scan on its worker, never on the GUI thread.
|
|
fn current_surface_usages(&self, subject: Id, surface: Surface) -> Vec<Usage> {
|
|
match surface {
|
|
Surface::Saved(diagram) => {
|
|
usage::saved_usages_for(self.project(), subject, Some(diagram))
|
|
}
|
|
Surface::Process(owner) => {
|
|
self.behavior
|
|
.topology
|
|
.diagram_usages(self.project(), owner, subject)
|
|
}
|
|
Surface::Scenario(owner) => {
|
|
self.behavior
|
|
.scenario
|
|
.diagram_usages(self.project(), owner, subject)
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(super) fn diagram_usage_envelope(&self, event: &Event) -> bool {
|
|
match event {
|
|
Event::Open(context) => {
|
|
context.document == self.canvas_document
|
|
&& context.revision == self.document.revision()
|
|
&& self.project().contains(context.subject)
|
|
}
|
|
Event::Close(token)
|
|
| Event::Search(token, _)
|
|
| Event::Page(token, _)
|
|
| Event::Move(token, _)
|
|
| Event::Activate(token)
|
|
| Event::Destination(token, _)
|
|
| Event::Choose(token, _) => self
|
|
.diagram_usages
|
|
.as_ref()
|
|
.is_some_and(|panel| panel.token == *token),
|
|
}
|
|
}
|
|
|
|
pub(super) fn diagram_usage_event(&mut self, event: Event) -> Task<Message> {
|
|
if !self.diagram_usage_envelope(&event) {
|
|
return Task::none();
|
|
}
|
|
if let Event::Open(context) = event {
|
|
if !self.usage_navigation_available() {
|
|
self.status = "Apply or revert pending edits before opening diagram navigation. Your inputs are retained.".into();
|
|
return Task::none();
|
|
}
|
|
let mut panel = Panel::new(context, Vec::new());
|
|
panel.loading = true;
|
|
let filter_id = format!("diagram-usage-filter-{}", panel.token);
|
|
self.diagram_usages = Some(panel);
|
|
self.model_menu = None;
|
|
// Only an accepted new chooser supersedes the previous reveal.
|
|
// Rejected/stale panel envelopes leave its first measurement intact.
|
|
self.pending_diagram_reveal = None;
|
|
self.pending_scenario_reveal_visit = None;
|
|
self.diagram_navigation_focus = None;
|
|
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
|
return Task::batch([
|
|
self.start_diagram_lookup(),
|
|
iced::widget::operation::focus(filter_id),
|
|
]);
|
|
}
|
|
let panel = self
|
|
.diagram_usages
|
|
.as_ref()
|
|
.expect("admitted chooser token");
|
|
if matches!(event, Event::Close(_)) {
|
|
self.diagram_usages = None;
|
|
if self.page == Page::Text {
|
|
self.restore_source_editor_focus();
|
|
}
|
|
return Task::none();
|
|
}
|
|
if panel.context.document != self.canvas_document {
|
|
self.diagram_usages = None;
|
|
return Task::none();
|
|
}
|
|
if panel.context.revision != self.document.revision() {
|
|
return self
|
|
.refresh_diagram_lookup("The model changed. Review the refreshed destinations.");
|
|
}
|
|
if panel.loading {
|
|
// Search and Close remain useful while loading; queued activation
|
|
// must not perform a synchronous fallback or choose an old row.
|
|
if let Event::Search(_, query) = event {
|
|
self.diagram_usages.as_mut().unwrap().search = query;
|
|
}
|
|
return Task::none();
|
|
}
|
|
match event {
|
|
Event::Move(_, direction) => {
|
|
let panel = self.diagram_usages.as_ref().unwrap();
|
|
let count = panel.choices(self.project()).len();
|
|
let panel = self.diagram_usages.as_mut().unwrap();
|
|
if count > 0 {
|
|
panel.cursor = if direction < 0 {
|
|
(panel.cursor + count - 1) % count
|
|
} else {
|
|
(panel.cursor + 1) % count
|
|
};
|
|
panel.page = panel.cursor / 6;
|
|
}
|
|
}
|
|
Event::Activate(_) => {
|
|
let panel = self.diagram_usages.as_ref().unwrap();
|
|
if let Some(event) = panel.choices(self.project()).get(panel.cursor).cloned() {
|
|
return self.diagram_usage_event(event);
|
|
}
|
|
}
|
|
Event::Search(_, query) => {
|
|
let panel = self.diagram_usages.as_mut().unwrap();
|
|
panel.search = query;
|
|
panel.page = 0;
|
|
panel.cursor = 0;
|
|
}
|
|
Event::Page(_, page) => {
|
|
let panel = self.diagram_usages.as_mut().unwrap();
|
|
panel.page = page;
|
|
panel.cursor = page.saturating_mul(6);
|
|
}
|
|
Event::Destination(_, surface) => {
|
|
let panel = self.diagram_usages.as_mut().unwrap();
|
|
if surface.is_none() || panel.usages.iter().any(|u| Some(u.surface) == surface) {
|
|
panel.destination = surface;
|
|
panel.page = 0;
|
|
panel.cursor = 0;
|
|
}
|
|
}
|
|
Event::Choose(_, requested) => {
|
|
if !self.usage_navigation_available() {
|
|
return Task::none();
|
|
}
|
|
let subject = self.diagram_usages.as_ref().unwrap().context.subject;
|
|
let fresh = self
|
|
.current_surface_usages(subject, requested.surface)
|
|
.into_iter()
|
|
.find(|u| u.subject == requested.subject && u.target == requested.target);
|
|
let Some(fresh) = fresh else {
|
|
return self.refresh_diagram_lookup(
|
|
"This representation changed. Review the refreshed destinations.",
|
|
);
|
|
};
|
|
if self.reveal_diagram_usage(&fresh) {
|
|
self.diagram_usages = None;
|
|
self.status = format!(
|
|
"{} · {} in {}",
|
|
self.project().named(subject),
|
|
fresh.role,
|
|
self.project().named(fresh.surface.id())
|
|
);
|
|
return Task::batch(["model-explorer", "model-browser", "inspector"].map(
|
|
|id| {
|
|
iced::widget::operation::snap_to(
|
|
id,
|
|
iced::widget::scrollable::RelativeOffset::START,
|
|
)
|
|
},
|
|
));
|
|
}
|
|
}
|
|
Event::Open(_) | Event::Close(_) => unreachable!(),
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
/// Explicit saved-Scenario reveal runs before generic Apply. An absent target
|
|
/// never selects the owner or another saved arrangement as a fallback.
|
|
pub(super) fn reveal_saved_scenario(&mut self, diagram: Id, selected: Id) -> Task<Message> {
|
|
if self.diagram_usages.is_some() || !self.usage_navigation_available() {
|
|
self.status = "Apply or revert pending edits before revealing a saved Scenario. Your inputs are retained.".into();
|
|
return Task::none();
|
|
}
|
|
if presentation::checked_owner(self.project(), Representation::Saved { diagram }).is_none()
|
|
{
|
|
return Task::none();
|
|
}
|
|
let Some(found) = self
|
|
.current_surface_usages(selected, Surface::Saved(diagram))
|
|
.into_iter()
|
|
.find(|row| matches!(row.target, Target::Whole | Target::ScenarioObject(_)))
|
|
else {
|
|
self.status = "This object has no visible target in that saved Scenario.".into();
|
|
return Task::none();
|
|
};
|
|
self.reveal_diagram_usage(&found);
|
|
Task::none()
|
|
}
|
|
|
|
pub(super) fn reveal_diagram_usage(&mut self, usage: &Usage) -> bool {
|
|
if !self.usage_navigation_available() {
|
|
return false;
|
|
}
|
|
let previous_layer = self.layer;
|
|
let scenario_representation = match usage.surface {
|
|
Surface::Saved(diagram) if self.saved_scenario_diagram(diagram) => {
|
|
Some(Representation::Saved { diagram })
|
|
}
|
|
Surface::Scenario(owner) => Some(Representation::Derived { owner }),
|
|
_ => None,
|
|
};
|
|
if let Some(representation) = scenario_representation {
|
|
let Some(owner) = presentation::checked_owner(self.project(), representation) else {
|
|
return false;
|
|
};
|
|
if !self
|
|
.behavior
|
|
.scenario
|
|
.representation_usages(self.project(), representation, usage.subject)
|
|
.iter()
|
|
.any(|row| row == usage)
|
|
{
|
|
return false;
|
|
}
|
|
// Finish target/camera admission before changing the App route. The
|
|
// checked immutable owner also makes the following entry infallible.
|
|
let revealed = match representation {
|
|
Representation::Derived { .. } => self.behavior.scenario.reveal_usage(
|
|
self.document.project(),
|
|
self.canvas_document,
|
|
owner,
|
|
&usage.target,
|
|
),
|
|
Representation::Saved { .. } => self.behavior.scenario.reveal_representation_usage(
|
|
self.document.project(),
|
|
self.canvas_document,
|
|
owner,
|
|
representation,
|
|
&usage.target,
|
|
),
|
|
};
|
|
if !revealed {
|
|
return false;
|
|
}
|
|
if !self.enter_scenario_representation(representation) {
|
|
return false;
|
|
}
|
|
}
|
|
let p = self.document.project();
|
|
let (selected, placement) = match &usage.target {
|
|
Target::Whole => (usage.surface.id(), None),
|
|
Target::Placement(id) => {
|
|
let Some(node) = p
|
|
.diagrams
|
|
.get(&usage.surface.id())
|
|
.and_then(|d| d.nodes.get(id))
|
|
else {
|
|
return false;
|
|
};
|
|
(node.element, Some(*id))
|
|
}
|
|
Target::Port { port, placement } => (*port, Some(*placement)),
|
|
Target::StateRow {
|
|
placement,
|
|
relation,
|
|
} => (*relation, Some(*placement)),
|
|
Target::Edge(id)
|
|
| Target::ProcessLink(id)
|
|
| Target::ScenarioObject(id)
|
|
| Target::AllocationGrouping { relation: id, .. }
|
|
| Target::ProcessPreviewLink { relation: id, .. } => (*id, None),
|
|
Target::ProcessOccurrence(endpoint) => (endpoint.occurrence, None),
|
|
};
|
|
match usage.surface {
|
|
Surface::Saved(id) => {
|
|
let Some(diagram) = p.diagrams.get(&id) else {
|
|
return false;
|
|
};
|
|
self.layer = diagram.layer;
|
|
self.active_diagram = Some(id);
|
|
if let Some(owner) = diagram.projection.scenario() {
|
|
self.behavior.active_owner = Some(owner);
|
|
self.page = Page::Behavior;
|
|
} else if let Some(machine) = diagram.projection.state_machine() {
|
|
let viewport = self.behavior.diagram.viewport.unwrap_or(iced::Size::new(
|
|
(self.window_size.width - 610.).max(280.),
|
|
(self.window_size.height - 320.).max(240.),
|
|
));
|
|
let (zoom, pan) = usage::camera_for(&usage.target, usage.bounds, viewport);
|
|
self.behavior.diagram = behavior::state_view::State {
|
|
diagram: Some(id),
|
|
placement,
|
|
viewport: Some(viewport),
|
|
zoom,
|
|
pan,
|
|
fit: false,
|
|
..Default::default()
|
|
};
|
|
self.behavior.active_owner = Some(machine);
|
|
self.page = Page::Behavior;
|
|
} else {
|
|
let viewport = self.canvas_size.unwrap_or(iced::Size::new(
|
|
(self.window_size.width - 550.).max(280.),
|
|
(self.window_size.height - 284.).max(240.),
|
|
));
|
|
(self.zoom, self.pan) =
|
|
usage::camera_for(&usage.target, usage.bounds, viewport);
|
|
self.fit_to_view = false;
|
|
self.page = Page::Diagram;
|
|
}
|
|
}
|
|
Surface::Process(owner) => {
|
|
if !self.behavior.topology.reveal_usage(
|
|
p,
|
|
self.canvas_document,
|
|
owner,
|
|
&usage.target,
|
|
) {
|
|
return false;
|
|
}
|
|
self.layer = p.elements[&owner].layer();
|
|
self.behavior.active_owner = Some(owner);
|
|
self.page = Page::Behavior;
|
|
}
|
|
Surface::Scenario(owner) => {
|
|
self.layer = p.elements[&owner].layer();
|
|
self.behavior.active_owner = Some(owner);
|
|
self.page = Page::Behavior;
|
|
}
|
|
}
|
|
if scenario_representation.is_none() {
|
|
self.behavior.scenario.forget_view();
|
|
}
|
|
self.selected = selected;
|
|
self.selected_node = placement;
|
|
self.model_menu = None;
|
|
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
|
self.selected_bend = None;
|
|
self.connection = None;
|
|
self.last_workspace_page = self.page;
|
|
if previous_layer != self.layer {
|
|
self.data_state = Default::default();
|
|
self.explorer_index = crate::explorer::Index::new(self.project(), self.layer);
|
|
self.explorer_focus = None;
|
|
self.explorer_group = None;
|
|
self.explorer_page = 0;
|
|
}
|
|
self.normalize_creation();
|
|
self.reveal_explorer_selection();
|
|
self.refresh_draft();
|
|
self.diagram_navigation_focus = Some(usage::Focus {
|
|
context: Context {
|
|
document: self.canvas_document,
|
|
revision: self.document.revision(),
|
|
subject: usage.subject,
|
|
},
|
|
usage: usage.clone(),
|
|
selected,
|
|
});
|
|
self.pending_scenario_reveal_visit = scenario_representation.and_then(|representation| {
|
|
let owner = presentation::checked_owner(self.project(), representation)?;
|
|
self.behavior
|
|
.scenario
|
|
.context(
|
|
self.canvas_document,
|
|
owner,
|
|
self.document.revision(),
|
|
self.canvas_epoch,
|
|
)
|
|
.map(|context| context.visit)
|
|
});
|
|
self.pending_diagram_reveal = Some((
|
|
Context {
|
|
document: self.canvas_document,
|
|
revision: self.document.revision(),
|
|
subject: usage.subject,
|
|
},
|
|
usage.clone(),
|
|
self.canvas_epoch,
|
|
));
|
|
true
|
|
}
|
|
|
|
/// Validated presentation identity. Edit selection and Delete capture remain
|
|
/// semantic; a port owner or allocation work box is only a visual marker.
|
|
pub(crate) fn diagram_focus(&self, surface: Surface) -> Option<&Usage> {
|
|
let focus = self.diagram_navigation_focus.as_ref()?;
|
|
let current_surface = match surface {
|
|
Surface::Saved(id) => {
|
|
self.active_diagram == Some(id)
|
|
&& matches!(self.page, Page::Diagram | Page::Behavior)
|
|
&& (!self.saved_scenario_diagram(id)
|
|
|| (self.page == Page::Behavior
|
|
&& self.behavior.scenario.representation()
|
|
== Some(Representation::Saved { diagram: id })))
|
|
}
|
|
Surface::Process(owner) => {
|
|
self.page == Page::Behavior && self.behavior.active_owner == Some(owner)
|
|
}
|
|
Surface::Scenario(owner) => {
|
|
self.page == Page::Behavior
|
|
&& self.behavior.active_owner == Some(owner)
|
|
&& self.behavior.scenario.representation()
|
|
== Some(Representation::Derived { owner })
|
|
}
|
|
};
|
|
(current_surface
|
|
&& focus.usage.surface == surface
|
|
&& focus.context.document == self.canvas_document
|
|
&& focus.context.revision == self.document.revision()
|
|
&& focus.selected == self.selected)
|
|
.then_some(&focus.usage)
|
|
}
|
|
}
|