Add persisted state diagram authoring and context-bound gestures

This commit is contained in:
2026-09-06 03:02:38 +03:00
parent af31c8a11d
commit e0b22108a8
14 changed files with 4316 additions and 753 deletions
@@ -0,0 +1,221 @@
//! 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 {
Message::StateDiagram(e)
}
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<_>>();
if !missing.is_empty() {
let position = Position {
x: (48. - state.pan.x) / state.zoom,
y: (90. - state.pan.y) / state.zoom,
};
out = out.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,
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(canvas.map(event));
let base: Element<'_, Message> = container(out.spacing(8))
.padding(14)
.width(Fill)
.height(Fill)
.into();
with_form(app, machine, base)
}
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
}
}