289 lines
9.3 KiB
Rust
289 lines
9.3 KiB
Rust
//! State workspace controls. Persistent semantics and layout live in the model.
|
||
use super::state_edit::{Event, Tool};
|
||
use super::*;
|
||
use iced::widget::{Column, column, opaque, stack};
|
||
|
||
pub struct State {
|
||
pub diagram: Option<Id>,
|
||
pub placement: Option<Id>,
|
||
pub tool: Tool,
|
||
pub viewport: Option<Size>,
|
||
pub zoom: f32,
|
||
pub pan: iced::Vector,
|
||
pub fit: bool,
|
||
}
|
||
impl Default for State {
|
||
fn default() -> Self {
|
||
Self {
|
||
diagram: None,
|
||
placement: None,
|
||
tool: Tool::Select,
|
||
viewport: None,
|
||
zoom: 0.8,
|
||
pan: iced::Vector::new(24., 48.),
|
||
fit: true,
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn diagram(app: &App, machine: Id) -> Option<Id> {
|
||
let valid = |id: &Id| {
|
||
app.project()
|
||
.diagrams
|
||
.get(id)
|
||
.is_some_and(|d| d.projection.state_machine() == Some(machine))
|
||
};
|
||
app.behavior
|
||
.diagram
|
||
.diagram
|
||
.filter(valid)
|
||
.or_else(|| app.project().diagrams.keys().find(|id| valid(id)).copied())
|
||
}
|
||
|
||
fn event(e: Event) -> Message {
|
||
match e {
|
||
Event::Selection(selection) => Message::DiagramSelection(selection),
|
||
other => Message::StateDiagram(other),
|
||
}
|
||
}
|
||
|
||
pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Element<'a, Message> {
|
||
let Some(id) = diagram(app, machine) else {
|
||
let base = out.push(container(column![
|
||
text("Create a state diagram").size(20),
|
||
label("Place the machine’s existing regions and states in an editable view. Creating another view leaves the existing views unchanged."),
|
||
button("Create diagram").padding(12).on_press(event(Event::CreateView(machine))),
|
||
row![
|
||
action("Add region…", Message::Behavior(super::Event::States(states::Event::Start(machine, states::Tool::Region, app.selected)))),
|
||
action("Add state…", Message::Behavior(super::Event::States(states::Event::Start(machine, states::Tool::Vertex, app.selected)))),
|
||
transition_details(machine, app.selected),
|
||
].spacing(5),
|
||
].spacing(14)).padding(24).style(style::card)).padding(18).into();
|
||
return with_form(app, machine, base);
|
||
};
|
||
let context = app
|
||
.state_context()
|
||
.expect("a visible state diagram has a context");
|
||
let p = app.project();
|
||
let state = &app.behavior.diagram;
|
||
let views = p
|
||
.diagrams
|
||
.values()
|
||
.filter(|d| d.projection.state_machine() == Some(machine))
|
||
.map(|d| Choice {
|
||
id: d.id,
|
||
label: d.name.clone(),
|
||
})
|
||
.collect::<Vec<_>>();
|
||
let current = views.iter().find(|v| v.id == id).cloned();
|
||
out = out.push(
|
||
row![
|
||
pick_list(views, current, Message::BehaviorDiagram)
|
||
.padding(8)
|
||
.text_size(12)
|
||
.width(Fill),
|
||
action("+ View", event(Event::CreateView(machine))),
|
||
action("Fit", event(Event::Fit(context))),
|
||
action("−", event(Event::Zoom(context, state.zoom / 1.2))),
|
||
label(format!("{:.0}%", state.zoom * 100.)),
|
||
action("+", event(Event::Zoom(context, state.zoom * 1.2))),
|
||
]
|
||
.spacing(5)
|
||
.align_y(iced::Center),
|
||
);
|
||
|
||
let tool = |name: &'static str, value: Tool| {
|
||
button(text(name).size(12))
|
||
.padding([8, 10])
|
||
.style(if state.tool == value {
|
||
button::primary
|
||
} else {
|
||
style::nav
|
||
})
|
||
.on_press(event(Event::Choose(value)))
|
||
};
|
||
let kinds = [
|
||
Kind::State,
|
||
Kind::Mode,
|
||
Kind::Initial,
|
||
Kind::Final,
|
||
Kind::Choice,
|
||
Kind::Junction,
|
||
Kind::Fork,
|
||
Kind::Join,
|
||
Kind::ShallowHistory,
|
||
Kind::DeepHistory,
|
||
Kind::EntryPoint,
|
||
Kind::ExitPoint,
|
||
Kind::Terminate,
|
||
];
|
||
let selected_kind = if let Tool::Vertex(kind) = state.tool {
|
||
Some(kind)
|
||
} else {
|
||
None
|
||
};
|
||
out = out.push(
|
||
row![
|
||
tool("Select / move", Tool::Select),
|
||
pick_list(kinds.to_vec(), selected_kind, |k| event(Event::Choose(
|
||
Tool::Vertex(k)
|
||
)))
|
||
.placeholder("+ State / symbol")
|
||
.padding(8)
|
||
.text_size(12),
|
||
tool("+ Region", Tool::Region),
|
||
tool("Transition", Tool::Transition),
|
||
]
|
||
.spacing(4),
|
||
);
|
||
|
||
let missing = state_edit::unplaced(p, id)
|
||
.into_iter()
|
||
.map(|element| Choice {
|
||
id: element,
|
||
label: format!("{} · {}", p.named(element), p.elements[&element].kind()),
|
||
})
|
||
.collect::<Vec<_>>();
|
||
// Keep the optional picker in its own stable slot. Inserting it as a
|
||
// sibling before the canvas would replace the positional Iced focus tree.
|
||
// Reserve the picker height even when all elements are placed. Group Hide
|
||
// and Undo must not resize the canvas and silently refit its camera.
|
||
let mut missing_controls = Column::new().height(34);
|
||
if !missing.is_empty() {
|
||
let position = Position {
|
||
x: (48. - state.pan.x) / state.zoom,
|
||
y: (90. - state.pan.y) / state.zoom,
|
||
};
|
||
missing_controls = missing_controls.push(
|
||
pick_list(missing, None::<Choice>, move |c| {
|
||
event(Event::Place {
|
||
context,
|
||
element: c.id,
|
||
position,
|
||
})
|
||
})
|
||
.placeholder("Show an element not yet in this view…")
|
||
.padding(8)
|
||
.text_size(12)
|
||
.width(Fill),
|
||
);
|
||
}
|
||
let canvas: Element<'_, Event> = canvas::Canvas::new(super::state_canvas::StateCanvas {
|
||
project: p,
|
||
diagram: id,
|
||
context,
|
||
selected: app.selected,
|
||
selection: app.diagram_selection.as_ref(),
|
||
navigation_focus: app
|
||
.diagram_focus(crate::diagram_usage::Surface::Saved(id))
|
||
.map(|usage| usage.bounds),
|
||
tool: state.tool,
|
||
zoom: state.zoom,
|
||
pan: state.pan,
|
||
editable: !app.draft_dirty() && !app.source_dirty() && !app.behavior.has_pending(),
|
||
})
|
||
.width(Fill)
|
||
.height(Fill)
|
||
.into();
|
||
out = out.push(
|
||
column![
|
||
container(missing_controls).id("state-missing-picker"),
|
||
selection_controls(app),
|
||
crate::diagram::focus::wrap(
|
||
canvas.map(event),
|
||
context,
|
||
crate::app::diagram_removal::focus_enabled(app),
|
||
app.busy,
|
||
Message::DiagramTraverse,
|
||
)
|
||
]
|
||
.spacing(8)
|
||
.height(Fill),
|
||
);
|
||
let base: Element<'_, Message> = container(out.spacing(8))
|
||
.padding(14)
|
||
.width(Fill)
|
||
.height(Fill)
|
||
.into();
|
||
with_form(app, machine, base)
|
||
}
|
||
|
||
fn selection_controls(app: &App) -> Element<'_, Message> {
|
||
use crate::app::multiselect::{Event as SelectionEvent, placements};
|
||
let snapshot = app.multiselect_snapshot();
|
||
let count = snapshot.as_ref().map_or(0, |s| s.targets().len());
|
||
let mut controls = row![label(format!("{count} selected"))]
|
||
.spacing(7)
|
||
.align_y(iced::Center)
|
||
.height(32);
|
||
if let Some(snapshot) = snapshot.filter(|s| s.targets().len() > 1) {
|
||
if placements(&snapshot).is_some() {
|
||
controls = controls.push(action(
|
||
"Hide selected",
|
||
Message::DiagramSelection(SelectionEvent::Hide(snapshot.clone())),
|
||
));
|
||
}
|
||
controls = controls
|
||
.push(action(
|
||
"Clear",
|
||
Message::DiagramSelection(SelectionEvent::Clear(snapshot.context())),
|
||
))
|
||
.push(
|
||
label(format!(
|
||
"Properties: {} only",
|
||
crate::views::short(app.project().named(app.selected), 10)
|
||
))
|
||
.wrapping(iced::widget::text::Wrapping::None),
|
||
);
|
||
}
|
||
let hint = if app.behavior.diagram.tool == Tool::Select {
|
||
"Shift+click adds or removes from selection"
|
||
} else {
|
||
"Use Select / move to select multiple elements"
|
||
};
|
||
controls
|
||
.push(crate::views::diagram_help(
|
||
crate::guide::Topic::States,
|
||
hint,
|
||
))
|
||
.into()
|
||
}
|
||
|
||
fn transition_details(machine: Id, selected: Id) -> Element<'static, Message> {
|
||
action(
|
||
"New transition with details…",
|
||
Message::Behavior(super::Event::States(states::Event::Start(
|
||
machine,
|
||
states::Tool::Transition,
|
||
selected,
|
||
))),
|
||
)
|
||
.into()
|
||
}
|
||
|
||
fn with_form<'a>(app: &'a App, machine: Id, base: Element<'a, Message>) -> Element<'a, Message> {
|
||
if let Some(form) = states::form(app, machine) {
|
||
stack![
|
||
base,
|
||
opaque(
|
||
container(
|
||
container(
|
||
scrollable(form).height((app.window_size.height - 250.).clamp(280., 610.))
|
||
)
|
||
.padding(18)
|
||
.width(600)
|
||
.style(style::card)
|
||
)
|
||
.center(Fill)
|
||
.style(|_| container::Style {
|
||
background: Some(Color::from_rgba(0.08, 0.12, 0.18, 0.38).into()),
|
||
..Default::default()
|
||
})
|
||
)
|
||
]
|
||
.into()
|
||
} else {
|
||
base
|
||
}
|
||
}
|