//! 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, pub placement: Option, pub tool: Tool, pub viewport: Option, 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 { 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::>(); 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::>(); // 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::, 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 selection", Message::DiagramSelection(SelectionEvent::Clear(snapshot.context())), )) .push( label(format!( "Properties edit only {}", crate::views::short(app.project().named(app.selected), 18) )) .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" }; column![ controls, label(hint).wrapping(iced::widget::text::Wrapping::None) ] .height(56) .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 } }