Integrate shared behavior canvas with current form focus and recovery

This commit is contained in:
2026-09-11 10:45:44 -07:00
parent 638d06c4e3
commit 6969c825a8
18 changed files with 4813 additions and 2060 deletions
+172 -9
View File
@@ -319,6 +319,7 @@ pub struct FilePrompt {
#[derive(Debug, Clone)]
pub enum Message {
BehaviorControl(behavior::canvas_workspace::Intent),
Architecture(architecture_authoring::Event),
Home(crate::home::Event),
CheckpointTick,
@@ -453,6 +454,9 @@ pub struct App {
menu_sequence: u64,
pub canvas_document: Id,
pub canvas_epoch: u64,
/// Camera batches survive their own updates, never external presentation changes.
pub camera_epoch: u64,
camera_boundary_epoch: u64,
pub selected_bend: Option<(Id, usize)>,
pub exact_strings: bool,
pub document: Document,
@@ -556,6 +560,8 @@ impl App {
menu_sequence: 0,
canvas_document: Id::new_v4(),
canvas_epoch: 0,
camera_epoch: 0,
camera_boundary_epoch: 0,
selected_bend: None,
exact_strings: false,
document,
@@ -1033,7 +1039,10 @@ impl App {
)
.into();
crate::behavior::scenario::focus::root(
crate::hosted::view::overlay(self, self.reference_overlay(base)),
behavior::canvas_workspace::focus_root(
crate::hosted::view::overlay(self, self.reference_overlay(base)),
behavior::canvas_workspace::Scope::input_owner(self),
),
self.scenario_focus_owner(),
)
}
@@ -1091,6 +1100,105 @@ impl App {
])
}
pub fn update(&mut self, message: Message) -> Task<Message> {
if let Message::BehaviorControl(intent) = message {
return self.behavior_control(intent);
}
let camera = matches!(
&message,
Message::ScenarioCanvas(crate::behavior::scenario::CanvasEvent::Camera(..))
| Message::TopologyCanvas(crate::behavior::topology::CanvasEvent::Camera(..))
);
let before = self.camera_boundary();
let task = self.update_inner(message);
if !camera && self.camera_boundary() != before {
self.camera_epoch = self.camera_epoch.wrapping_add(1);
self.camera_boundary_epoch = self.canvas_epoch;
}
task
}
fn behavior_control(&mut self, intent: behavior::canvas_workspace::Intent) -> Task<Message> {
use behavior::canvas_workspace::{self, Event as Camera, Scope};
use behavior::scenario::CanvasEvent as Scenario;
use behavior::topology::CanvasEvent as Process;
if Scope::input_owner(self) != Some(intent.scope) {
return Task::none();
}
let mut message = *intent.message;
let scope = intent.scope;
let valid_scenario = |c: behavior::scenario::Context| {
c.document == scope.document
&& c.scenario == scope.owner
&& c.revision == scope.revision
&& self.camera_toolbar_epoch(c.epoch)
};
let valid_process = |c: behavior::topology::Context| {
c.document == scope.document
&& c.owner == scope.owner
&& c.revision == scope.revision
&& self.camera_toolbar_epoch(c.epoch)
};
// This whitelist contains only the production presentation controls.
// A stamped toolbar cannot smuggle an arbitrary authoring command.
match &mut message {
Message::ScenarioCanvas(Scenario::Camera(c, lifetime, Camera::Focus))
if valid_scenario(*c) && *lifetime == scope.epoch =>
{
return canvas_workspace::focus_task(scope, Camera::Focus);
}
Message::TopologyCanvas(Process::Camera(c, lifetime, Camera::Focus))
if valid_process(*c) && *lifetime == scope.epoch =>
{
return canvas_workspace::focus_task(scope, Camera::Focus);
}
Message::ScenarioCanvas(
Scenario::Zoom(c, _)
| Scenario::Choose(c, _)
| Scenario::Focus(c)
| Scenario::Shelf(c, _),
) if valid_scenario(*c) => {
c.epoch = self.canvas_epoch;
}
Message::TopologyCanvas(
Process::Zoom(c, _) | Process::Choose(c, _) | Process::Focus(c),
) if valid_process(*c) => {
c.epoch = self.canvas_epoch;
}
Message::Behavior(behavior::Event::Topology(behavior::topology::Event::Shelf(_)))
if self.topology_context().is_some() => {}
_ => return Task::none(),
}
let task = self.update(message);
// Reapplying Fit or a tool also ends a held gesture. Focus is stamped
// after the action, for Iced's final-view ordering.
if self.camera_epoch == scope.epoch {
self.camera_epoch = self.camera_epoch.wrapping_add(1);
self.camera_boundary_epoch = self.canvas_epoch;
}
let focus = Scope::input_owner(self).map_or_else(Task::none, |scope| {
canvas_workspace::focus_task(scope, Camera::ControlFocus(intent.name))
});
Task::batch([task, focus])
}
fn camera_toolbar_epoch(&self, epoch: u64) -> bool {
epoch >= self.camera_boundary_epoch && epoch <= self.canvas_epoch
}
fn camera_boundary(&self) -> impl PartialEq + use<> {
(
self.canvas_epoch,
self.canvas_document,
self.document.revision(),
crate::behavior::selected_owner(self),
self.page,
self.window_size,
crate::behavior::canvas_workspace::keyboard_enabled(self),
self.behavior.has_pending(),
self.draft_dirty(),
self.source_dirty(),
self.behavior.topology.camera_boundary(),
self.behavior.scenario.camera_boundary(),
)
}
fn update_inner(&mut self, message: Message) -> Task<Message> {
if let Some(task) = self.reference_message(&message) {
return task;
}
@@ -1400,6 +1508,7 @@ impl App {
}
fn handle(&mut self, message: Message) -> Task<Message> {
match message {
Message::BehaviorControl(intent) => return self.behavior_control(intent),
Message::Architecture(event) => return self.architecture_event(event),
Message::Home(event) => return self.home_event(event),
Message::CheckpointTick => return self.checkpoint_tick(),
@@ -1464,6 +1573,49 @@ impl App {
}
return Task::none();
}
if let CanvasEvent::Camera(_, lifetime, input) = event {
use crate::behavior::canvas_workspace::{self, Event};
if lifetime != self.camera_epoch
|| !self.scenario_context().is_some_and(|c| {
c.document == context.document
&& c.scenario == context.scenario
&& c.revision == context.revision
})
{
return Task::none();
}
let scope = canvas_workspace::Scope {
document: context.document,
owner: context.scenario,
revision: context.revision,
epoch: lifetime,
};
if self.behavior.has_pending()
|| self.draft_dirty()
|| self.source_dirty()
|| !canvas_workspace::keyboard_enabled(self)
{
return Task::none();
}
match input {
Event::Focus
| Event::Traverse(_)
| Event::ControlFocus(_)
| Event::ToggleFocus
| Event::Leave => {
return canvas_workspace::focus_task(scope, input);
}
Event::CancelGesture => {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1)
}
input => {
if self.behavior.scenario.camera(input) {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
}
}
return Task::none();
}
if self.scenario_context() != Some(context) {
return Task::none();
}
@@ -1477,9 +1629,11 @@ impl App {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
CanvasEvent::Zoom(_, scale) => {
if self.behavior.scenario.zoom(scale) {
let mut state = std::mem::take(&mut self.behavior.scenario);
if state.camera_zoom(self.project(), context.scenario, scale) {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
self.behavior.scenario = state;
}
CanvasEvent::OpenUse(_, occurrence) => {
if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty()
@@ -1601,13 +1755,22 @@ impl App {
..
} => {
let mut state = std::mem::take(&mut self.behavior.scenario);
let result = state.begin_occurrence(
self.project(),
context,
tool,
slot,
lifeline,
);
let result = if tool == scenario::Tool::Lifeline {
state
.update(
self.project(),
scenario::Event::Start(context.scenario, tool),
)
.map(|_| ())
} else {
state.begin_occurrence(
self.project(),
context,
tool,
slot,
lifeline,
)
};
self.behavior.scenario = state;
match result {
Ok(()) => {
+60 -15
View File
@@ -15,18 +15,15 @@ impl App {
epoch: self.canvas_epoch,
})
}
pub(super) fn reveal_process_selection(&self) -> Task<Message> {
let Some(context) = self.topology_context() else {
return Task::none();
};
let Some(offset) =
self.behavior
.topology
.selection_offset(self.project(), context.owner, self.selected)
else {
return Task::none();
};
iced::widget::operation::scroll_to("process-canvas", offset)
pub(super) fn reveal_process_selection(&mut self) -> Task<Message> {
if let Some(context) = self.topology_context() {
let mut state = std::mem::take(&mut self.behavior.topology);
if state.reveal_selection(self.project(), context.owner, self.selected) {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
self.behavior.topology = state;
}
Task::none()
}
pub(super) fn edit_process_canvas(&mut self, event: CanvasEvent) -> Task<Message> {
let context = event.context();
@@ -52,6 +49,47 @@ impl App {
}
return Task::none();
}
if let CanvasEvent::Camera(_, lifetime, input) = event {
use crate::behavior::canvas_workspace::{self, Event};
if lifetime != self.camera_epoch
|| !self.topology_context().is_some_and(|c| {
c.document == context.document
&& c.owner == context.owner
&& c.revision == context.revision
})
{
return Task::none();
}
let scope = canvas_workspace::Scope {
document: context.document,
owner: context.owner,
revision: context.revision,
epoch: lifetime,
};
if self.behavior.has_pending()
|| self.draft_dirty()
|| self.source_dirty()
|| !canvas_workspace::keyboard_enabled(self)
{
return Task::none();
}
match input {
Event::Focus
| Event::Traverse(_)
| Event::ControlFocus(_)
| Event::ToggleFocus
| Event::Leave => {
return canvas_workspace::focus_task(scope, input);
}
Event::CancelGesture => self.canvas_epoch = self.canvas_epoch.wrapping_add(1),
input => {
if self.behavior.topology.camera(input) {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
}
}
return Task::none();
}
if self.topology_context() != Some(context) {
return Task::none();
}
@@ -81,6 +119,7 @@ impl App {
.first()
.copied()
.unwrap_or(endpoint.occurrence),
resolved.defining_sequence,
);
self.behavior.active_owner = Some(resolved.defining_sequence);
self.select(endpoint.occurrence);
@@ -118,14 +157,18 @@ impl App {
}
}
CanvasEvent::Choose(_, tool) => {
if self.behavior.has_pending() || !self.apply() {
if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty() {
self.status =
"Apply or cancel pending edits before choosing a process tool.".into();
return Task::none();
}
self.behavior.topology.choose(tool);
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
CanvasEvent::Zoom(_, zoom) => {
self.behavior.topology.zoom(zoom);
let mut state = std::mem::take(&mut self.behavior.topology);
state.camera_zoom(self.project(), context.owner, zoom);
self.behavior.topology = state;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
CanvasEvent::Expand(_, id) => {
@@ -149,7 +192,9 @@ impl App {
.get(&target)
.is_some_and(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain))
{
self.behavior.topology.remember(context.owner, occurrence);
self.behavior
.topology
.remember(context.owner, occurrence, target);
self.behavior.active_owner = Some(target);
}
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
@@ -49,6 +49,10 @@ pub(super) struct State {
}
impl App {
pub(crate) fn reference_input_active(&self) -> bool {
self.reference_authoring.opened.is_some()
}
fn reference_draft(&self, host: &Host, path: &str) -> Option<&Draft> {
match host {
Host::Inspector
+56 -108
View File
@@ -14,6 +14,7 @@ use iced::{
};
use oa_core::*;
use std::collections::BTreeMap;
pub(crate) mod canvas_workspace;
#[cfg(test)]
mod contextual_source_tests;
pub(crate) mod creation;
@@ -40,6 +41,7 @@ mod scenario_scroll {
pub(super) fn observe<'a, R: renderer::Renderer + 'a>(
context: scenario::Context,
area: scenario::ScrollArea,
process: bool,
restore: Option<scenario::ScrollRestore>,
content: impl Into<Element<'a, Message, Theme, R>>,
) -> Element<'a, Message, Theme, R> {
@@ -47,43 +49,8 @@ mod scenario_scroll {
content: content.into(),
context,
area,
process: false,
process,
restore,
reveal: None,
})
}
pub(super) fn observe_canvas<'a, R: renderer::Renderer + 'a>(
context: scenario::Context,
restore: Option<scenario::ScrollRestore>,
reveal: Option<scenario::ScrollReveal>,
content: impl Into<Element<'a, Message, Theme, R>>,
) -> Element<'a, Message, Theme, R> {
Element::new(Observer {
content: content.into(),
context,
area: scenario::ScrollArea::Canvas,
process: false,
restore,
reveal,
})
}
pub(super) fn observe_topology<'a>(
context: topology::Context,
area: scenario::ScrollArea,
content: impl Into<Element<'a, Message>>,
) -> Element<'a, Message> {
Element::new(Observer {
content: content.into(),
context: scenario::Context {
document: context.document,
scenario: context.owner,
revision: context.revision,
epoch: context.epoch,
},
area,
process: true,
restore: None,
reveal: None,
})
}
struct Observer<'a, R = Renderer> {
@@ -92,14 +59,12 @@ mod scenario_scroll {
area: scenario::ScrollArea,
process: bool,
restore: Option<scenario::ScrollRestore>,
reveal: Option<scenario::ScrollReveal>,
}
type Report = Option<(bool, Id, Id, Vector)>;
struct ObservedScroll {
identity: (Id, Id),
report: Report,
restored: Option<Id>,
revealed: Option<(Id, scenario::Slot)>,
}
#[derive(Default)]
struct Translation(Option<Vector>);
@@ -138,41 +103,6 @@ mod scenario_scroll {
self.applied = true;
}
}
struct RevealTranslation {
request: scenario::ScrollReveal,
applied: bool,
}
impl widget::Operation for RevealTranslation {
fn traverse(&mut self, _: &mut dyn FnMut(&mut dyn widget::Operation)) {}
fn scrollable(
&mut self,
_: Option<&widget::Id>,
bounds: Rectangle,
_: Rectangle,
translation: Vector,
state: &mut dyn widget::operation::Scrollable,
) {
let reveal = self.request;
if !reveal.y.is_finite() || !reveal.padding.is_finite() || bounds.height <= 0. {
return;
}
let padding = reveal.padding.max(0.).min(bounds.height / 2.);
let y = if reveal.y - padding < translation.y {
(reveal.y - padding).max(0.)
} else if reveal.y + padding > translation.y + bounds.height {
(reveal.y + padding - bounds.height).max(0.)
} else {
translation.y
};
// Preserve horizontal position and reveal only once per invocation
// and explicit destination. Later ordinary scrolling stays ordinary.
state.scroll_to(widget::operation::scrollable::AbsoluteOffset {
x: None,
y: Some(y),
});
self.applied = true;
}
}
impl<R: renderer::Renderer> Observer<'_, R> {
fn report(
&mut self,
@@ -200,27 +130,6 @@ mod scenario_scroll {
);
operation.applied.then_some(request.token)
});
if let Some(reveal) = self.reveal.filter(|request| {
!self.process
&& receipt.is_none()
&& tree.state.downcast_ref::<ObservedScroll>().revealed
!= Some((request.token, request.slot))
}) {
let mut operation = RevealTranslation {
request: reveal,
applied: false,
};
self.content.as_widget_mut().operate(
&mut tree.children[0],
layout,
renderer,
&mut operation,
);
if operation.applied {
tree.state.downcast_mut::<ObservedScroll>().revealed =
Some((reveal.token, reveal.slot));
}
}
let mut offset = Translation::default();
self.content.as_widget_mut().operate(
&mut tree.children[0],
@@ -281,7 +190,6 @@ mod scenario_scroll {
identity: (self.context.document, self.context.scenario),
report: None,
restored: None,
revealed: None,
})
}
fn children(&self) -> Vec<widget::Tree> {
@@ -415,10 +323,9 @@ mod scenario_scroll {
fn observer(height: f32, context: scenario::Context) -> Observer<'static, ()> {
Observer {
context,
area: scenario::ScrollArea::Canvas,
area: scenario::ScrollArea::Page,
process: false,
restore: None,
reveal: None,
content: scrollable(iced::widget::Space::new().width(200.).height(height))
.id("observer-test")
.width(200.)
@@ -486,7 +393,7 @@ mod scenario_scroll {
std::time::Instant::now(),
))
};
for area in [ScrollArea::Canvas, ScrollArea::Page] {
for area in [ScrollArea::Page] {
for returned_height in [600., 250.] {
let (project, [a, _, _], [use_id, _, _]) = navigation_fixture();
let mut app = App::with_project(project.clone());
@@ -1235,6 +1142,7 @@ struct Scene {
selected: Id,
scale: f32,
scroll_offset: iced::Vector,
camera_pan: iced::Vector,
interaction: Option<(scenario::Context, scenario::CanvasTool, bool)>,
pending_placement: Option<(scenario::Slot, Option<Id>)>,
lanes: BTreeMap<Id, f32>,
@@ -1289,6 +1197,7 @@ impl Scene {
let mut scene = Self {
scale: 1.,
scroll_offset: iced::Vector::ZERO,
camera_pan: iced::Vector::ZERO,
boxes: vec![],
lines: vec![],
size: Size::new(700., 200.),
@@ -1951,9 +1860,12 @@ impl canvas::Program<Message> for Scene {
}
_ => cursor,
};
let world = cursor
.position_in(bounds)
.map(|p| Point::new(p.x / self.scale, p.y / self.scale));
let world = cursor.position_in(bounds).map(|p| {
Point::new(
(p.x - self.camera_pan.x) / self.scale,
(p.y - self.camera_pan.y) / self.scale,
)
});
match event {
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
if !visible {
@@ -1980,7 +1892,7 @@ impl canvas::Program<Message> for Scene {
return Some(Action::request_redraw().and_capture());
}
if let CanvasTool::Add(tool) = tool {
if !tool.canvas_occurrence() {
if !tool.canvas_occurrence() && tool != scenario::Tool::Lifeline {
return None;
}
state.press = Some(SequencePress::Occurrence {
@@ -1990,10 +1902,18 @@ impl canvas::Program<Message> for Scene {
});
return Some(Action::request_redraw().and_capture());
}
if self.reorder_handle().is_some_and(|r| r.contains(point)) {
if let Some(occurrence) = self
.hit(point)
.filter(|id| self.rows.contains_key(id))
.or_else(|| {
self.reorder_handle()
.filter(|r| r.contains(point))
.map(|_| self.selected)
})
{
state.press = Some(SequencePress::Reorder {
occurrence: self.selected,
parent: self.rows[&self.selected].0,
occurrence,
parent: self.rows[&occurrence].0,
start: cursor.position()? - self.scroll_offset,
moved: false,
});
@@ -2068,6 +1988,18 @@ impl canvas::Program<Message> for Scene {
);
}
}
SequencePress::Reorder {
occurrence,
moved: false,
..
} => {
return Some(
Action::publish(Message::ScenarioCanvas(CanvasEvent::Select(
context, occurrence,
)))
.and_capture(),
);
}
SequencePress::Reorder {
occurrence,
parent,
@@ -2105,6 +2037,7 @@ impl canvas::Program<Message> for Scene {
) -> Vec<canvas::Geometry> {
let mut f = Frame::new(renderer, bounds.size());
f.fill_rectangle(Point::ORIGIN, bounds.size(), Color::WHITE);
f.translate(self.camera_pan);
f.scale(self.scale);
for b in &self.boxes {
let outline = matches!(
@@ -2216,9 +2149,12 @@ impl canvas::Program<Message> for Scene {
}
if let Some((context, tool, editable)) = self.interaction {
let active = editable && state.context == Some(context) && state.tool == tool;
let point = cursor
.position_in(bounds)
.map(|p| Point::new(p.x / self.scale, p.y / self.scale));
let point = cursor.position_in(bounds).map(|p| {
Point::new(
(p.x - self.camera_pan.x) / self.scale,
(p.y - self.camera_pan.y) / self.scale,
)
});
let source = active.then_some(state.source).flatten();
let reorder = active.then_some(state.press).flatten().and_then(|press| {
if let SequencePress::Reorder { parent, .. } = press {
@@ -2263,6 +2199,18 @@ impl canvas::Program<Message> for Scene {
} else {
None
};
if reorder.is_some()
&& slot.is_none()
&& let Some(point) = point
{
f.fill_text(Text {
content: "Other operand: move stays in the current sequence".into(),
position: Point::new(point.x + 8., point.y + 12.),
size: (11. / self.scale).into(),
color: Color::from_rgb(0.75, 0.2, 0.15),
..Default::default()
});
}
if let Some(slot) = slot {
f.stroke(
&Path::line(
@@ -0,0 +1,243 @@
//! Shared presentation and input contract; contains no model commands.
use crate::app::{App, Message};
use iced::widget::{button, column, container, opaque, row, scrollable, stack, text, tooltip};
use iced::{Element, Length::Fill, Point, Theme, Vector};
use oa_core::Id;
mod control;
mod focus;
mod viewport;
pub use control::Control;
pub use viewport::Viewport;
pub fn keyboard_enabled(app: &crate::app::App) -> bool {
!app.busy
&& !app.hosted.visible
&& app.workflow.is_none()
&& app.file_prompt.is_none()
&& !app.delete_pending
&& app.pending.is_none()
&& app.model_menu.is_none()
&& !app.reference_input_active()
}
pub fn controls<'a>(
scope: Scope,
keyboard: bool,
scale: f32,
details: Message,
focus: Option<Message>,
zoom: impl Fn(Option<f32>) -> Message,
publish: impl Fn(Event) -> Message + Copy + 'a,
) -> Element<'a, Message> {
let control = |name: &'static str, label: &'static str, message: Option<Message>| {
let message = message.map(|message| intent(scope, name, message));
Control::new(
button(text(label).size(12))
.padding(6)
.on_press_maybe(message.clone()),
scope,
name,
keyboard,
message,
publish,
)
};
row![
control("Details", "Details", Some(details)),
tooltip(control("Canvas", "Canvas", focus),
container(text("F6: enter/leave canvas controls · Tab: next · Enter/Space: activate · Esc: leave").size(12)).padding(7).style(crate::style::card),
tooltip::Position::Bottom),
control("Fit", "Fit", Some(zoom(None))),
control("ZoomOut", "−", Some(zoom(Some(scale / 1.25)))),
text(format!("{:.0}%", scale * 100.)).size(12),
control("ZoomIn", "+", Some(zoom(Some(scale * 1.25)))),
control("ActualSize", "100%", Some(zoom(Some(1.)))),
].spacing(4).align_y(iced::Center).into()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Scope {
pub document: Id,
pub owner: Id,
pub revision: u64,
pub epoch: u64,
}
impl Scope {
pub fn current(app: &App) -> Option<Self> {
if let Some(context) = app.scenario_context() {
Some(Self {
document: context.document,
owner: context.scenario,
revision: context.revision,
epoch: app.camera_epoch,
})
} else {
let context = app.topology_context()?;
Some(Self {
document: context.document,
owner: context.owner,
revision: context.revision,
epoch: app.camera_epoch,
})
}
}
pub fn input_owner(app: &App) -> Option<Self> {
if keyboard_enabled(app)
&& !app.behavior.has_pending()
&& !app.draft_dirty()
&& !app.source_dirty()
{
Self::current(app)
} else {
None
}
}
pub fn control_id(self, name: &str) -> iced::advanced::widget::Id {
iced::advanced::widget::Id::from(format!(
"behavior-control:{}:{}:{}:{name}",
self.document, self.owner, self.revision
))
}
}
/// Only a live presentation intent may carry a toolbar action across its own pan.
#[derive(Debug, Clone)]
pub struct Intent {
pub scope: Scope,
pub name: &'static str,
pub message: Box<Message>,
}
pub fn intent(scope: Scope, name: &'static str, message: Message) -> Message {
Message::BehaviorControl(Intent {
scope,
name,
message: Box::new(message),
})
}
#[derive(Debug, Clone, Copy)]
pub enum Event {
CancelGesture,
Pan(Vector),
Zoom { factor: f32, at: Point, base: f32 },
Focus,
Traverse(bool),
ControlFocus(&'static str),
ToggleFocus,
Leave,
}
pub use focus::{focus_task, root as focus_root};
/// Fold relative input in event order. The base scale is used only on leaving Fit.
pub fn change(pan: &mut Vector, zoom: &mut Option<f32>, fit: &mut bool, event: Event) -> bool {
match event {
Event::Pan(delta) if delta.x.is_finite() && delta.y.is_finite() => {
let next = *pan + delta;
if next.x.is_finite() && next.y.is_finite() && next != *pan {
*pan = next;
true
} else {
false
}
}
Event::Zoom { factor, at, base }
if factor.is_finite()
&& factor > 0.
&& base.is_finite()
&& base > 0.
&& at.x.is_finite()
&& at.y.is_finite() =>
{
let old = if *fit { base } else { zoom.unwrap_or(base) };
let next = (old * factor).clamp(0.05, 3.);
if old == next {
return false;
}
let anchor = Vector::new(at.x, at.y);
let next_pan = anchor - (anchor - *pan) * (next / old);
if !next_pan.x.is_finite() || !next_pan.y.is_finite() {
return false;
}
*pan = next_pan;
*zoom = Some(next);
*fit = false;
true
}
_ => false,
}
}
/// Constant-width labelled palette. Arming a tool never reallocates the canvas.
pub fn palette<'a>(
scope: Scope,
keyboard: bool,
publish: impl Fn(Event) -> Message + Copy + 'a,
entries: impl IntoIterator<Item = (&'static str, &'static str, bool, Option<Message>)>,
) -> Element<'a, Message> {
let mut tools = column![].spacing(3);
for (glyph, label, selected, message) in entries {
let message = message.map(|message| intent(scope, glyph, message));
tools = tools.push(tooltip(
container(Control::new(
button(text(glyph).size(12))
.width(34)
.height(30)
.padding(3)
.style(if selected {
button::primary
} else {
crate::style::nav
})
.on_press_maybe(message.clone()),
scope,
glyph,
keyboard,
message,
publish,
))
.id(format!("behavior-tool:{glyph}")),
container(text(label).size(12))
.padding(7)
.style(crate::style::card),
tooltip::Position::Left,
));
}
container(scrollable(tools).id(scope.control_id("palette-scroll")))
.width(40)
.height(Fill)
.padding(3)
.style(crate::style::panel)
.into()
}
/// Header and palette are permanent. Details/editors overlay the same viewport.
pub fn host<'a, Message: 'a, R: iced::advanced::text::Renderer + 'a>(
header: Element<'a, Message, Theme, R>,
palette: Element<'a, Message, Theme, R>,
canvas: Element<'a, Message, Theme, R>,
editor: Option<Element<'a, Message, Theme, R>>,
) -> Element<'a, Message, Theme, R> {
let mut body = stack![canvas];
if let Some(editor) = editor {
body = body.push(
container(opaque(
container(editor)
.width(300)
.height(Fill)
.style(crate::style::card),
))
.align_right(Fill)
.height(Fill)
.padding(8),
);
}
column![header, row![body, palette].height(Fill)]
.spacing(6)
.padding(8)
.width(Fill)
.height(Fill)
.into()
}
#[cfg(test)]
pub(crate) mod tests;
@@ -0,0 +1,258 @@
//! A real focusable keyboard target around the existing styled pointer button.
use super::{Event as CameraEvent, Scope, focus::Member};
use iced::advanced::{
Clipboard, Layout, Shell, Widget, layout, overlay, renderer,
widget::{Operation, Tree, operation, tree},
};
use iced::{Border, Element, Event, Length, Rectangle, Size, Theme, Vector, keyboard, mouse};
#[derive(Default)]
struct Input {
identity: Option<iced::advanced::widget::Id>,
focused: bool,
}
impl operation::Focusable for Input {
fn is_focused(&self) -> bool {
self.focused
}
fn focus(&mut self) {
self.focused = true;
}
fn unfocus(&mut self) {
self.focused = false;
}
}
pub struct Control<'a, Message, R = iced::Renderer>
where
R: renderer::Renderer,
{
content: Element<'a, Message, Theme, R>,
scope: Scope,
name: &'static str,
enabled: bool,
activate: Option<Message>,
publish: Box<dyn Fn(CameraEvent) -> Message + 'a>,
}
impl<'a, Message, R: renderer::Renderer> Control<'a, Message, R> {
pub fn new(
content: impl Into<Element<'a, Message, Theme, R>>,
scope: Scope,
name: &'static str,
enabled: bool,
activate: Option<Message>,
publish: impl Fn(CameraEvent) -> Message + 'a,
) -> Self {
Self {
content: content.into(),
scope,
name,
enabled: enabled && activate.is_some(),
activate,
publish: Box::new(publish),
}
}
}
impl<Message: Clone, R: renderer::Renderer> Widget<Message, Theme, R> for Control<'_, Message, R> {
fn tag(&self) -> tree::Tag {
tree::Tag::of::<Input>()
}
fn state(&self) -> tree::State {
tree::State::new(Input {
identity: Some(self.scope.control_id(self.name)),
focused: false,
})
}
fn children(&self) -> Vec<Tree> {
vec![Tree::new(self.content.as_widget())]
}
fn diff(&self, tree: &mut Tree) {
let state = tree.state.downcast_mut::<Input>();
let identity = self.scope.control_id(self.name);
if state.identity.as_ref() != Some(&identity) || !self.enabled {
*state = Input {
identity: Some(identity),
focused: false,
};
}
tree.diff_children(std::slice::from_ref(&self.content));
}
fn size(&self) -> Size<Length> {
self.content.as_widget().size()
}
fn layout(&mut self, tree: &mut Tree, renderer: &R, limits: &layout::Limits) -> layout::Node {
self.content
.as_widget_mut()
.layout(&mut tree.children[0], renderer, limits)
}
fn operate(
&mut self,
tree: &mut Tree,
layout: Layout<'_>,
renderer: &R,
operation: &mut dyn Operation,
) {
let id = self.scope.control_id(self.name);
operation.container(Some(&id), layout.bounds());
if self.enabled {
operation.custom(Some(&id), layout.bounds(), &mut Member(self.scope));
operation.focusable(
Some(&id),
layout.bounds(),
tree.state.downcast_mut::<Input>(),
);
}
operation.traverse(&mut |operation| {
self.content.as_widget_mut().operate(
&mut tree.children[0],
layout,
renderer,
operation,
);
});
}
fn update(
&mut self,
tree: &mut Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &R,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
if shell.is_event_captured() {
return;
}
let state = tree.state.downcast_mut::<Input>();
if self.enabled {
if let Event::Keyboard(keyboard::Event::KeyPressed {
key,
modifiers,
repeat,
..
}) = event
&& state.focused
{
use keyboard::{Key, key::Named};
let action = match key.as_ref() {
Key::Named(Named::Tab) => {
Some((self.publish)(CameraEvent::Traverse(modifiers.shift())))
}
Key::Named(Named::Escape) => Some((self.publish)(CameraEvent::Leave)),
Key::Named(Named::F6) if modifiers.is_empty() && !repeat => {
Some((self.publish)(CameraEvent::ToggleFocus))
}
Key::Named(Named::Enter | Named::Space) if modifiers.is_empty() && !repeat => {
self.activate.clone()
}
_ => None,
};
if let Some(action) = action {
shell.publish(action);
shell.capture_event();
shell.request_redraw();
return;
}
}
if matches!(
event,
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
) {
if !layout
.bounds()
.intersection(viewport)
.is_some_and(|bounds| cursor.is_over(bounds))
{
state.focused = false;
}
}
}
if matches!(event, Event::Window(iced::window::Event::Unfocused)) {
state.focused = false;
}
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
}
fn draw(
&self,
tree: &Tree,
renderer: &mut R,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.content.as_widget().draw(
&tree.children[0],
renderer,
theme,
style,
layout,
cursor,
viewport,
);
if self.enabled && tree.state.downcast_ref::<Input>().focused {
renderer.fill_quad(
renderer::Quad {
bounds: layout.bounds(),
border: Border {
color: crate::style::INK,
width: 2.,
..Default::default()
},
..Default::default()
},
iced::Color::TRANSPARENT,
);
}
}
fn mouse_interaction(
&self,
tree: &Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &R,
) -> mouse::Interaction {
self.content.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
}
fn overlay<'a>(
&'a mut self,
tree: &'a mut Tree,
layout: Layout<'a>,
renderer: &R,
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'a, Message, Theme, R>> {
self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
impl<'a, Message: Clone + 'a, R: renderer::Renderer + 'a> From<Control<'a, Message, R>>
for Element<'a, Message, Theme, R>
{
fn from(control: Control<'a, Message, R>) -> Self {
Element::new(control)
}
}
@@ -0,0 +1,293 @@
//! Explicit canvas entry; ordinary initial Tab continues to belong to Inspector.
use super::{Event, Scope};
use iced::advanced::widget::{Id, Operation, Tree, operation};
use iced::advanced::{Clipboard, Layout, Shell, Widget, layout, overlay, renderer};
use iced::{Element, Event as InputEvent, Length, Rectangle, Size, Theme, Vector, mouse};
pub(super) struct Member(pub Scope);
pub fn focus_task<Message: Send + 'static>(scope: Scope, event: Event) -> iced::Task<Message> {
iced::advanced::widget::operate(Collect {
scope,
valid: false,
event,
members: vec![],
focused: None,
palette: None,
})
}
struct Collect {
scope: Scope,
valid: bool,
event: Event,
members: Vec<(Id, Rectangle)>,
focused: Option<usize>,
palette: Option<(Rectangle, Rectangle)>,
}
impl<T: Send + 'static> Operation<T> for Collect {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation<T>)) {
visit(self);
}
fn custom(&mut self, id: Option<&Id>, bounds: Rectangle, value: &mut dyn std::any::Any) {
if let Some(owner) = value.downcast_ref::<Owner>() {
self.valid = owner.0 == Some(self.scope);
}
if self.valid
&& value
.downcast_ref::<Member>()
.is_some_and(|m| m.0 == self.scope)
&& let Some(id) = id
{
self.members.push((id.clone(), bounds));
}
}
fn focusable(&mut self, id: Option<&Id>, _: Rectangle, value: &mut dyn operation::Focusable) {
if value.is_focused() {
self.focused = self
.members
.iter()
.position(|(member, _)| Some(member) == id)
.or(self.focused);
}
}
fn scrollable(
&mut self,
id: Option<&Id>,
bounds: Rectangle,
content: Rectangle,
_: Vector,
_: &mut dyn operation::Scrollable,
) {
if id == Some(&self.scope.control_id("palette-scroll")) {
self.palette = Some((bounds, content));
}
}
fn finish(&self) -> operation::Outcome<T> {
let canvas = self.scope.control_id("viewport");
// An enabled viewport witnesses the live lifetime. Hidden/dirty/modal
// owners expose no members; a delayed request cannot clear their focus.
if !self.valid || !self.members.iter().any(|(id, _)| *id == canvas) {
return operation::Outcome::None;
}
let target = match self.event {
Event::Focus => Some(canvas),
Event::ControlFocus(name) => Some(self.scope.control_id(name)),
Event::ToggleFocus if self.focused.is_none() => Some(canvas),
Event::ToggleFocus | Event::Leave if self.focused.is_some() => None,
Event::Traverse(back) => {
let Some(current) = self.focused else {
return operation::Outcome::None;
};
let next = if back {
(current + self.members.len() - 1) % self.members.len()
} else {
(current + 1) % self.members.len()
};
Some(self.members[next].0.clone())
}
_ => return operation::Outcome::None,
};
if target
.as_ref()
.is_some_and(|target| !self.members.iter().any(|(id, _)| id == target))
{
return operation::Outcome::None;
}
let reveal = target.as_ref().and_then(|target| {
let (_, field) = self.members.iter().find(|(id, _)| id == target)?;
let (bounds, content) = self.palette?;
// Only the palette has member controls inside this scrollable.
(field.x >= bounds.x && field.x < bounds.x + bounds.width).then(|| {
(field.center_y() - bounds.y - bounds.height / 2.)
.clamp(0., (content.height - bounds.height).max(0.))
})
});
operation::Outcome::Chain(Box::new(Apply {
scope: self.scope,
valid: false,
target,
members: self.members.iter().map(|(id, _)| id.clone()).collect(),
palette: self.scope.control_id("palette-scroll"),
reveal,
}))
}
}
struct Apply {
scope: Scope,
valid: bool,
target: Option<Id>,
members: Vec<Id>,
palette: Id,
reveal: Option<f32>,
}
impl<T> Operation<T> for Apply {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation<T>)) {
visit(self);
}
fn custom(&mut self, _: Option<&Id>, _: Rectangle, value: &mut dyn std::any::Any) {
if let Some(owner) = value.downcast_ref::<Owner>() {
self.valid = owner.0 == Some(self.scope);
}
}
fn focusable(&mut self, id: Option<&Id>, _: Rectangle, state: &mut dyn operation::Focusable) {
if !self.valid {
return;
}
if self
.target
.as_ref()
.is_some_and(|target| Some(target) == id)
{
state.focus();
} else if self.target.is_some() || id.is_some_and(|id| self.members.contains(id)) {
state.unfocus();
}
}
fn scrollable(
&mut self,
id: Option<&Id>,
_: Rectangle,
_: Rectangle,
_: Vector,
state: &mut dyn operation::Scrollable,
) {
if self.valid
&& id == Some(&self.palette)
&& let Some(y) = self.reveal
{
state.scroll_to(operation::scrollable::AbsoluteOffset {
x: None,
y: Some(y),
});
}
}
}
// Every Collect/Apply pass observes current ownership before touching any field.
struct Owner(Option<Scope>);
struct Root<'a, Message, R: renderer::Renderer> {
owner: Option<Scope>,
content: Element<'a, Message, Theme, R>,
}
pub fn root<'a, Message: 'a, R: renderer::Renderer + 'a>(
content: impl Into<Element<'a, Message, Theme, R>>,
owner: Option<Scope>,
) -> Element<'a, Message, Theme, R> {
Element::new(Root {
owner,
content: content.into(),
})
}
impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Root<'_, Message, R> {
fn children(&self) -> Vec<Tree> {
vec![Tree::new(self.content.as_widget())]
}
fn diff(&self, tree: &mut Tree) {
tree.diff_children(std::slice::from_ref(&self.content));
}
fn size(&self) -> Size<Length> {
self.content.as_widget().size()
}
fn layout(&mut self, tree: &mut Tree, renderer: &R, limits: &layout::Limits) -> layout::Node {
self.content
.as_widget_mut()
.layout(&mut tree.children[0], renderer, limits)
}
fn operate(
&mut self,
tree: &mut Tree,
layout: Layout<'_>,
renderer: &R,
operation: &mut dyn Operation,
) {
operation.custom(None, layout.bounds(), &mut Owner(self.owner));
self.content
.as_widget_mut()
.operate(&mut tree.children[0], layout, renderer, operation);
}
fn update(
&mut self,
tree: &mut Tree,
event: &InputEvent,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &R,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
}
fn draw(
&self,
tree: &Tree,
renderer: &mut R,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.content.as_widget().draw(
&tree.children[0],
renderer,
theme,
style,
layout,
cursor,
viewport,
);
}
fn mouse_interaction(
&self,
tree: &Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &R,
) -> mouse::Interaction {
self.content.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
}
fn overlay<'b>(
&'b mut self,
tree: &'b mut Tree,
layout: Layout<'b>,
renderer: &R,
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'b, Message, Theme, R>> {
self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
@@ -0,0 +1,756 @@
use super::*;
use crate::app::{App, Message};
use iced::advanced::{Layout, Shell, layout, widget};
use iced::futures::{
StreamExt,
stream::{BoxStream, SelectAll},
};
use iced::{Rectangle, Size, mouse};
use iced_runtime::{
Action,
user_interface::{Cache, UserInterface},
};
use std::collections::HashMap;
type Pending = SelectAll<BoxStream<'static, Action<Message>>>;
// iced_winit drains messages while collecting immediately ready actions. A
// pending Task stream survives until its widget operation sends its result.
fn enqueue(task: iced::Task<Message>, ready: &mut Vec<Action<Message>>, pending: &mut Pending) {
if let Some(mut stream) = iced_runtime::task::into_stream(task) {
let waker = iced::futures::task::noop_waker();
let mut context = std::task::Context::from_waker(&waker);
loop {
match stream.poll_next_unpin(&mut context) {
std::task::Poll::Ready(Some(action)) => ready.push(action),
std::task::Poll::Ready(None) => break,
std::task::Poll::Pending => {
pending.push(stream);
break;
}
}
}
}
}
#[derive(Default)]
pub(crate) struct Geometry {
pub containers: HashMap<widget::Id, Rectangle>,
pub focused: Vec<(Option<widget::Id>, Rectangle, bool)>,
pub text_inputs: Vec<Rectangle>,
pub scrolls: HashMap<widget::Id, (Rectangle, Vector)>,
}
impl widget::Operation for Geometry {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
visit(self);
}
fn focusable(
&mut self,
id: Option<&widget::Id>,
bounds: Rectangle,
state: &mut dyn widget::operation::Focusable,
) {
self.focused.push((id.cloned(), bounds, state.is_focused()));
}
fn text_input(
&mut self,
_: Option<&widget::Id>,
bounds: Rectangle,
_: &mut dyn widget::operation::TextInput,
) {
self.text_inputs.push(bounds);
}
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
if let Some(id) = id {
self.containers.insert(id.clone(), bounds);
}
}
fn scrollable(
&mut self,
id: Option<&widget::Id>,
bounds: Rectangle,
_: Rectangle,
offset: Vector,
_: &mut dyn widget::operation::Scrollable,
) {
if let Some(id) = id {
self.scrolls.insert(id.clone(), (bounds, offset));
}
}
}
impl Geometry {
pub fn bounds(&self, id: &str) -> Rectangle {
self.containers[&widget::Id::from(id.to_owned())]
}
}
/// Mount the actual App root, production widgets and renderer. Events are
/// delivered in order with Iced's final-batch cursor, then fed through App::update.
/// This deliberately does not seed any private canvas gesture state.
pub(crate) struct Mounted {
pub renderer: iced::Renderer,
cache: Cache,
pub operations: usize,
}
impl Mounted {
pub async fn unshown() -> Self {
use iced::advanced::renderer::Headless;
let renderer = <iced::Renderer as Headless>::new(iced::Font::DEFAULT, 16.into(), None)
.await
.expect("production mounted tests require the supported WGPU headless renderer");
Self {
renderer,
cache: Cache::new(),
operations: 0,
}
}
pub async fn new(app: &mut App) -> Self {
let mut mounted = Self::unshown().await;
mounted.settle(app).await;
mounted
}
pub fn geometry(&mut self, app: &App) -> Geometry {
let mut geometry = Geometry::default();
self.operate(app, &mut geometry);
geometry
}
/// One operation pass, including every nested overlay, via installed Iced.
pub fn operate(&mut self, app: &App, operation: &mut dyn widget::Operation) {
let mut ui = UserInterface::build(
app.view(),
app.window_size,
std::mem::take(&mut self.cache),
&mut self.renderer,
);
ui.operate(&self.renderer, operation);
self.cache = ui.into_cache();
}
pub async fn settle(&mut self, app: &mut App) {
for _ in 0..3 {
self.batch(
app,
&[iced::Event::Window(iced::window::Event::RedrawRequested(
std::time::Instant::now(),
))],
mouse::Cursor::Unavailable,
)
.await;
}
}
pub async fn batch(
&mut self,
app: &mut App,
events: &[iced::Event],
cursor: mouse::Cursor,
) -> Vec<Message> {
let messages = self.input(app, events, cursor);
let mut ready = vec![];
let mut pending = Pending::new();
for message in messages.iter().cloned() {
enqueue(app.update(message), &mut ready, &mut pending);
}
self.drain(app, ready, pending).await;
messages
}
pub fn input(
&mut self,
app: &App,
events: &[iced::Event],
cursor: mouse::Cursor,
) -> Vec<Message> {
let mut messages = vec![];
let mut ui = UserInterface::build(
app.view(),
app.window_size,
std::mem::take(&mut self.cache),
&mut self.renderer,
);
let _ = ui.update(
events,
cursor,
&mut self.renderer,
&mut iced::advanced::clipboard::Null,
&mut messages,
);
self.cache = ui.into_cache();
messages
}
pub async fn perform(&mut self, app: &mut App, task: iced::Task<Message>) {
let mut ready = vec![];
let mut pending = Pending::new();
enqueue(task, &mut ready, &mut pending);
self.drain(app, ready, pending).await;
}
async fn drain(
&mut self,
app: &mut App,
mut ready: Vec<Action<Message>>,
mut pending: Pending,
) {
loop {
let mut outputs = vec![];
if !ready.is_empty() {
// Build only after all App messages in this cycle have run.
let mut ui = UserInterface::build(
app.view(),
app.window_size,
std::mem::take(&mut self.cache),
&mut self.renderer,
);
for action in ready.drain(..) {
match action {
Action::Output(message) => outputs.push(message),
Action::Widget(mut operation) => {
self.operations += 1;
loop {
ui.operate(&self.renderer, &mut *operation);
match operation.finish() {
widget::operation::Outcome::Chain(next) => operation = next,
widget::operation::Outcome::None
| widget::operation::Outcome::Some(()) => break,
}
}
}
action => {
panic!("Unexpected native action in mounted App harness: {action:?}")
}
}
}
self.cache = ui.into_cache();
}
for message in outputs {
enqueue(app.update(message), &mut ready, &mut pending);
}
if !ready.is_empty() {
continue;
}
let Some(action) = pending.next().await else {
break;
};
ready.push(action);
let waker = iced::futures::task::noop_waker();
let mut context = std::task::Context::from_waker(&waker);
while let std::task::Poll::Ready(Some(action)) = pending.poll_next_unpin(&mut context) {
ready.push(action);
}
}
}
pub fn pixels(&mut self, app: &App) -> Vec<u8> {
use iced::advanced::renderer::Headless;
let mut ui = UserInterface::build(
app.view(),
app.window_size,
std::mem::take(&mut self.cache),
&mut self.renderer,
);
// update initializes the nested overlay layout used by draw.
let mut messages = vec![];
let _ = ui.update(
&[],
mouse::Cursor::Unavailable,
&mut self.renderer,
&mut iced::advanced::clipboard::Null,
&mut messages,
);
assert!(
messages.is_empty(),
"An empty draw preparation must not drop App messages"
);
ui.draw(
&mut self.renderer,
&crate::style::theme(),
&iced::advanced::renderer::Style {
text_color: crate::style::INK,
},
mouse::Cursor::Unavailable,
);
self.cache = ui.into_cache();
self.renderer.screenshot(
Size::new(app.window_size.width as u32, app.window_size.height as u32),
1.,
crate::style::BG,
)
}
pub async fn click(&mut self, app: &mut App, point: Point) -> Vec<Message> {
self.batch(
app,
&[
iced::Event::Mouse(mouse::Event::CursorMoved { position: point }),
iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
iced::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
],
mouse::Cursor::Available(point),
)
.await
}
pub async fn pan(&mut self, app: &mut App, start: Point, end: Point) -> Vec<Message> {
self.batch(
app,
&[
iced::Event::Mouse(mouse::Event::CursorMoved { position: start }),
iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle)),
iced::Event::Mouse(mouse::Event::CursorMoved { position: end }),
iced::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Middle)),
],
mouse::Cursor::Available(end),
)
.await
}
pub fn scroll(&mut self, app: &App, id: &str, offset: f32) {
let mut operation = widget::operation::scrollable::scroll_to::<()>(
widget::Id::from(id.to_owned()),
widget::operation::scrollable::AbsoluteOffset {
x: None,
y: Some(offset),
},
);
self.operate(app, &mut operation);
}
}
pub(crate) fn key(named: iced::keyboard::key::Named) -> iced::Event {
use iced::keyboard::{self, Key};
iced::Event::Keyboard(keyboard::Event::KeyPressed {
key: Key::Named(named),
modified_key: Key::Named(named),
physical_key: keyboard::key::Physical::Unidentified(
keyboard::key::NativeCode::Unidentified,
),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::empty(),
text: None,
repeat: false,
})
}
#[test]
fn relative_camera_zoom_keeps_the_cursor_world_point_and_rejects_nonfinite_inputs() {
for scale in [0.05, 0.5, 1., 2.] {
let mut pan = Vector::new(-130., 75.);
let mut zoom = Some(scale);
let mut fit = false;
let at = Point::new(171., 93.);
let world = (at - Point::ORIGIN - pan) / scale;
change(
&mut pan,
&mut zoom,
&mut fit,
Event::Zoom {
factor: 1.25,
at,
base: scale,
},
);
let after = (at - Point::ORIGIN - pan) / zoom.unwrap();
assert!((after.x - world.x).abs() < 0.001 && (after.y - world.y).abs() < 0.001);
let before = (pan, zoom, fit);
for input in [
Event::Pan(Vector::new(f32::INFINITY, 0.)),
Event::Zoom {
factor: f32::NAN,
at,
base: scale,
},
Event::Zoom {
factor: 0.,
at,
base: scale,
},
] {
assert!(!change(&mut pan, &mut zoom, &mut fit, input));
assert_eq!((pan, zoom, fit), before);
}
}
}
#[test]
fn viewport_ordered_batch_uses_each_event_position_and_cancels_before_camera_motion() {
use iced::widget::Space;
let scope = Scope {
document: Id::new_v4(),
owner: Id::new_v4(),
revision: 2,
epoch: 0,
};
let mut view: Element<'_, Event, Theme, ()> = Viewport::new(
Space::new().width(Fill).height(Fill),
scope,
1.,
true,
|e| e,
)
.into();
let mut tree = widget::Tree::new(view.as_widget());
let node = view
.as_widget_mut()
.layout(
&mut tree,
&(),
&layout::Limits::new(Size::ZERO, Size::new(400., 300.)),
)
.move_to(Point::new(30., 80.));
let start = Point::new(90., 160.);
let middle = Point::new(125., 175.);
let end = Point::new(145., 185.);
let mut messages = vec![];
for event in [
mouse::Event::CursorMoved { position: start },
mouse::Event::ButtonPressed(mouse::Button::Middle),
mouse::Event::CursorMoved { position: middle },
mouse::Event::CursorMoved { position: end },
mouse::Event::ButtonReleased(mouse::Button::Middle),
] {
view.as_widget_mut().update(
&mut tree,
&iced::Event::Mouse(event),
Layout::new(&node),
mouse::Cursor::Available(end),
&(),
&mut iced::advanced::clipboard::Null,
&mut Shell::new(&mut messages),
&Rectangle::with_size(Size::new(700., 600.)),
);
}
assert!(matches!(messages.first(), Some(Event::CancelGesture)));
let total = messages
.iter()
.fold(Vector::ZERO, |total, event| match event {
Event::Pan(delta) => total + *delta,
_ => total,
});
assert_eq!(total, end - start);
}
pub(crate) fn scope(app: &App) -> Scope {
if let Some(context) = app.scenario_context() {
Scope {
document: context.document,
owner: context.scenario,
revision: context.revision,
epoch: app.camera_epoch,
}
} else {
let context = app.topology_context().expect("mounted behavior owner");
Scope {
document: context.document,
owner: context.owner,
revision: context.revision,
epoch: app.camera_epoch,
}
}
}
pub(crate) fn focused(mounted: &mut Mounted, app: &App, name: &str) -> bool {
let id = scope(app).control_id(name);
mounted
.geometry(app)
.focused
.iter()
.any(|(member, _, focused)| member.as_ref() == Some(&id) && *focused)
}
pub(crate) async fn tab_to(mounted: &mut Mounted, app: &mut App, name: &str) {
for _ in 0..32 {
if focused(mounted, app, name) {
return;
}
mounted
.batch(
app,
&[key(iced::keyboard::key::Named::Tab)],
mouse::Cursor::Unavailable,
)
.await;
}
panic!("Keyboard traversal did not reach {name}");
}
pub(crate) fn camera_delta(messages: &[Message]) -> Message {
messages.iter().find(|m| matches!(m,
Message::ScenarioCanvas(crate::behavior::scenario::CanvasEvent::Camera(_, _, Event::Pan(delta)))
| Message::TopologyCanvas(crate::behavior::topology::CanvasEvent::Camera(_, _, Event::Pan(delta))) if *delta != Vector::ZERO
)).expect("actual nonzero camera delta from a mounted gesture").clone()
}
pub(crate) async fn press_middle(mounted: &mut Mounted, app: &mut App, point: Point) {
mounted
.batch(
app,
&[
iced::Event::Mouse(mouse::Event::CursorMoved { position: point }),
iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle)),
],
mouse::Cursor::Available(point),
)
.await;
}
pub(crate) async fn release_middle(
mounted: &mut Mounted,
app: &mut App,
point: Point,
) -> Vec<Message> {
mounted
.batch(
app,
&[
iced::Event::Mouse(mouse::Event::CursorMoved { position: point }),
iced::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Middle)),
],
mouse::Cursor::Available(point),
)
.await
}
pub(crate) fn assert_drawn_focus(before: &[u8], after: &[u8], size: Size, bounds: Rectangle) {
let width = size.width as usize;
assert_eq!(before.len(), width * size.height as usize * 4);
assert_eq!(after.len(), before.len());
let mut changed = 0;
for y in bounds.y.ceil() as usize..(bounds.y + bounds.height).floor() as usize {
for x in bounds.x.ceil() as usize..(bounds.x + bounds.width).floor() as usize {
if x as f32 - bounds.x > 2.
&& bounds.x + bounds.width - x as f32 > 2.
&& y as f32 - bounds.y > 2.
&& bounds.y + bounds.height - y as f32 > 2.
{
continue;
}
let start = (y * width + x) * 4;
if before[start..start + 3] != after[start..start + 3] {
changed += 1;
}
}
}
assert!(
changed >= bounds.width.min(bounds.height) as usize,
"Focus must change visible border pixels, changed={changed}, bounds={bounds:?}"
);
}
pub(crate) fn character(value: &str) -> iced::Event {
let mut event = key(iced::keyboard::key::Named::Space);
if let iced::Event::Keyboard(iced::keyboard::Event::KeyPressed {
key,
modified_key,
text,
..
}) = &mut event
{
*key = iced::keyboard::Key::Character(value.into());
*modified_key = key.clone();
*text = Some(value.into());
}
event
}
/// Inspect the action without stripping the lifetime from retained packets.
pub(crate) fn intent_action(message: &Message) -> &Message {
match message {
Message::BehaviorControl(intent) => &intent.message,
message => message,
}
}
/// Deliberately retain a returned Chain to test the stronger ownership invariant.
/// Native Iced runs Chain passes synchronously; this is not a native interleaving.
pub(crate) async fn retained_focus_apply(
mounted: &mut Mounted,
app: &mut App,
) -> Box<dyn widget::Operation> {
let entry = mounted.input(
app,
&[key(iced::keyboard::key::Named::F6)],
mouse::Cursor::Unavailable,
);
assert_eq!(
entry.len(),
1,
"actual initial F6 must emit one canvas focus request"
);
let mut stream = iced_runtime::task::into_stream(app.update(entry[0].clone()))
.expect("F6 returns a widget Task");
let Some(Action::Widget(mut operation)) = stream.next().await else {
panic!("F6 first action must be Collect");
};
mounted.operate(app, &mut *operation);
match operation.finish() {
widget::operation::Outcome::Chain(apply) => apply,
_ => panic!("Live Collect must produce its guarded Apply pass"),
}
}
pub(crate) async fn overflow_last_tool(mounted: &mut Mounted, app: &mut App, last: &'static str) {
use iced::keyboard::key::Named;
let geometry = mounted.geometry(app);
let last_bounds = geometry.containers[&scope(app).control_id(last)];
let (rail, offset) = geometry.scrolls[&scope(app).control_id("palette-scroll")];
assert_eq!(offset.y, 0.);
assert!(
last_bounds.y >= rail.y + rail.height,
"last tool starts fully below the overflowing palette: tool={last_bounds:?}, rail={rail:?}"
);
mounted
.batch(app, &[key(Named::F6)], mouse::Cursor::Unavailable)
.await;
tab_to(mounted, app, last).await;
let geometry = mounted.geometry(app);
let mut visible = geometry.containers[&scope(app).control_id(last)];
let (rail, offset) = geometry.scrolls[&scope(app).control_id("palette-scroll")];
assert!(offset.y > 0., "keyboard traversal must scroll the palette");
visible.x -= offset.x;
visible.y -= offset.y;
assert!(
visible.y >= rail.y && visible.y + visible.height <= rail.y + rail.height + 0.5,
"the entire last tool is revealed: tool={visible:?}, rail={rail:?}"
);
let messages = mounted
.batch(app, &[key(Named::Enter)], mouse::Cursor::Unavailable)
.await;
assert!(
messages.iter().any(
|message| matches!(message, Message::BehaviorControl(intent) if intent.name == last)
),
"the revealed last tool is keyboard activated"
);
assert!(focused(mounted, app, last));
let on = mounted.pixels(app);
mounted
.batch(app, &[key(Named::Escape)], mouse::Cursor::Unavailable)
.await;
let off = mounted.pixels(app);
assert_drawn_focus(&off, &on, app.window_size, visible);
}
pub(crate) async fn retained_toolbar_boundaries(
mounted: &mut Mounted,
app: &mut App,
tool: &'static str,
) {
use iced::keyboard::key::Named;
for boundary in ["Chooser", "Dirty", "Picker"] {
let mut packets = vec![];
for name in ["Fit", tool, "Details", "Panels", "Canvas"] {
packets.push(capture_toolbar(mounted, app, name));
}
let lifetime = app.camera_epoch;
let epoch = app.canvas_epoch;
let process = app.behavior.topology.camera_boundary();
let scenario = app.behavior.scenario.camera_boundary();
match boundary {
"Chooser" => {
let draft = app.draft.as_ref().unwrap();
let id = crate::forms::reference_chooser::trigger::field_id(
draft.reference_generation(),
"/parent",
);
let bounds = mounted
.geometry(app)
.focused
.iter()
.find(|(field, _, _)| field.as_ref() == Some(&id))
.expect("real Inspector parent reference trigger")
.1;
let request = draft.reference_open(app.project(), "/parent", bounds);
let task = app.update(Message::FieldReferenceOpen(request));
mounted.perform(app, task).await;
assert!(app.reference_input_active());
mounted
.batch(app, &[key(Named::Escape)], mouse::Cursor::Unavailable)
.await;
assert!(!app.reference_input_active());
}
"Dirty" => {
let task = app.update(Message::Field(
"/name".into(),
"Unsaved camera boundary".into(),
));
mounted.perform(app, task).await;
assert!(app.draft_dirty());
// Every retained packet is also rejected while the owner is dirty.
for packet in packets.iter().cloned() {
let task = app.update(packet);
mounted.perform(app, task).await;
}
assert!(app.draft_dirty());
let task = app.update(Message::RevertProperties);
mounted.perform(app, task).await;
assert!(!app.draft_dirty());
}
"Picker" => {
// Public transitions, with cancellation injected at the external
// picker boundary. Never poll the native dialog future here.
let picker = app.update(Message::Save(true));
if cfg!(target_os = "linux") {
assert!(app.file_prompt.is_some());
mounted.perform(app, picker).await;
let task = app.update(Message::CancelFile);
mounted.perform(app, task).await;
} else {
assert!(app.busy, "Save As enters the real native-picker busy state");
drop(picker);
for packet in packets.iter().cloned() {
let task = app.update(packet);
mounted.perform(app, task).await;
}
let task = app.update(Message::SavePath(None));
mounted.perform(app, task).await;
assert!(!app.busy);
}
}
_ => unreachable!(),
}
assert_ne!(
app.camera_epoch, lifetime,
"{boundary} changes the presentation lifetime"
);
assert_eq!(
app.canvas_epoch, epoch,
"{boundary} cycle leaves semantic context unchanged"
);
let focus_before = mounted
.geometry(app)
.focused
.into_iter()
.filter(|(_, _, value)| *value)
.collect::<Vec<_>>();
let current_lifetime = app.camera_epoch;
for packet in packets {
let task = app.update(packet);
mounted.perform(app, task).await;
}
assert_eq!(
app.camera_epoch, current_lifetime,
"{boundary} retained intents cannot start a new lifetime"
);
assert!(
app.behavior.topology.camera_boundary() == process,
"{boundary} retained toolbar packet changed process presentation"
);
assert!(
app.behavior.scenario.camera_boundary() == scenario,
"{boundary} retained toolbar packet changed scenario presentation"
);
assert_eq!(
mounted
.geometry(app)
.focused
.into_iter()
.filter(|(_, _, value)| *value)
.collect::<Vec<_>>(),
focus_before,
"{boundary} retained toolbar packet stole field focus"
);
}
}
pub(crate) fn capture_toolbar(mounted: &mut Mounted, app: &App, name: &'static str) -> Message {
let point = mounted.geometry(app).containers[&scope(app).control_id(name)].center();
mounted
.input(
app,
&[
iced::Event::Mouse(mouse::Event::CursorMoved { position: point }),
iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
iced::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
],
mouse::Cursor::Available(point),
)
.into_iter()
.find(|message| matches!(message, Message::BehaviorControl(intent) if intent.name == name))
.expect("actual production toolbar packet")
}
@@ -0,0 +1,426 @@
use super::{Event as CameraEvent, Scope};
use iced::advanced::{
Clipboard, Layout, Shell, Widget, layout, overlay, renderer,
widget::{Operation, Tree, operation, tree},
};
use iced::{Border, Element, Event, Length, Point, Rectangle, Size, Theme, Vector};
use iced::{keyboard, mouse};
#[derive(Default)]
struct Input {
scope: Option<Scope>,
focused: bool,
pointer: Option<Point>,
pan: Option<(mouse::Button, Point)>,
space: bool,
bounds: Option<Rectangle>,
}
impl operation::Focusable for Input {
fn is_focused(&self) -> bool {
self.focused
}
fn focus(&mut self) {
self.focused = true;
}
fn unfocus(&mut self) {
self.focused = false;
self.pan = None;
self.space = false;
}
}
pub struct Viewport<'a, Message, R = iced::Renderer>
where
R: renderer::Renderer,
{
content: Element<'a, Message, Theme, R>,
scope: Scope,
scale: f32,
keyboard: bool,
blank_pan: bool,
publish: Box<dyn Fn(CameraEvent) -> Message + 'a>,
}
impl<'a, Message, R: renderer::Renderer> Viewport<'a, Message, R> {
pub fn new(
content: impl Into<Element<'a, Message, Theme, R>>,
scope: Scope,
scale: f32,
keyboard: bool,
publish: impl Fn(CameraEvent) -> Message + 'a,
) -> Self {
Self {
content: content.into(),
scope,
scale,
keyboard,
blank_pan: false,
publish: Box::new(publish),
}
}
pub fn pan_background(mut self, enabled: bool) -> Self {
self.blank_pan = enabled;
self
}
}
impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Viewport<'_, Message, R> {
fn tag(&self) -> tree::Tag {
tree::Tag::of::<Input>()
}
fn state(&self) -> tree::State {
tree::State::new(Input {
scope: Some(self.scope),
..Default::default()
})
}
fn children(&self) -> Vec<Tree> {
vec![Tree::new(self.content.as_widget())]
}
fn diff(&self, tree: &mut Tree) {
let state = tree.state.downcast_mut::<Input>();
if state.scope.is_none_or(|s| {
(s.document, s.owner, s.revision)
!= (self.scope.document, self.scope.owner, self.scope.revision)
}) {
*state = Input::default();
}
if state
.scope
.is_some_and(|scope| scope.epoch != self.scope.epoch)
|| !self.keyboard
{
state.pan = None;
state.space = false;
}
if !self.keyboard {
state.focused = false;
}
state.scope = Some(self.scope);
tree.diff_children(std::slice::from_ref(&self.content));
}
fn size(&self) -> Size<Length> {
Size::new(Length::Fill, Length::Fill)
}
fn layout(&mut self, tree: &mut Tree, renderer: &R, limits: &layout::Limits) -> layout::Node {
self.content
.as_widget_mut()
.layout(&mut tree.children[0], renderer, limits)
}
fn operate(
&mut self,
tree: &mut Tree,
layout: Layout<'_>,
renderer: &R,
operation: &mut dyn Operation,
) {
if self.keyboard {
let id = self.scope.control_id("viewport");
operation.custom(
Some(&id),
layout.bounds(),
&mut super::focus::Member(self.scope),
);
operation.focusable(
Some(&id),
layout.bounds(),
tree.state.downcast_mut::<Input>(),
);
}
self.content
.as_widget_mut()
.operate(&mut tree.children[0], layout, renderer, operation);
}
fn update(
&mut self,
tree: &mut Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &R,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
if shell.is_event_captured() {
return;
}
let state = tree.state.downcast_mut::<Input>();
let changed_bounds = state.bounds.is_some_and(|bounds| bounds != layout.bounds());
state.bounds = Some(layout.bounds());
if changed_bounds || !self.keyboard {
state.pan = None;
state.space = false;
}
if !self.keyboard {
state.focused = false;
}
if let Event::Mouse(mouse::Event::CursorMoved { position }) = event {
state.pointer = Some(*position);
}
let pointer = match event {
Event::Mouse(
mouse::Event::ButtonPressed(_)
| mouse::Event::ButtonReleased(_)
| mouse::Event::WheelScrolled { .. },
) => state.pointer.or_else(|| cursor.position()),
Event::Mouse(mouse::Event::CursorMoved { position }) => Some(*position),
_ => cursor.position().or(state.pointer),
};
let visible = layout.bounds().intersection(viewport);
let inside = pointer.is_some_and(|p| visible.is_some_and(|v| v.contains(p)))
&& cursor.position().is_some();
if !self.keyboard {
state.space = false;
}
let mut cancel = changed_bounds;
let mut camera = None;
let mut capture = false;
match event {
Event::Window(iced::window::Event::Unfocused | iced::window::Event::Resized(_)) => {
state.pan = None;
state.space = false;
state.focused = false;
}
Event::Mouse(mouse::Event::CursorLeft) => {
state.pan = None;
state.pointer = None;
}
Event::Mouse(mouse::Event::ButtonPressed(button)) => {
if state.pan.is_some_and(|(held, _)| held != *button) || !inside {
state.pan = None;
state.space = false;
cancel = true;
}
state.focused = inside && self.keyboard;
if inside
&& self.keyboard
&& (*button == mouse::Button::Middle
|| (*button == mouse::Button::Left && state.space))
{
state.pan = pointer.map(|p| (*button, p));
cancel = true;
camera = Some(CameraEvent::CancelGesture);
capture = true;
}
}
Event::Mouse(mouse::Event::CursorMoved { position }) => {
if let Some((button, previous)) = state.pan {
state.pan = Some((button, *position));
camera = Some(CameraEvent::Pan(*position - previous));
capture = true;
}
}
Event::Mouse(mouse::Event::ButtonReleased(button)) => {
if let Some((held, previous)) = state.pan
&& held == *button
{
state.pan = None;
camera = pointer
.filter(|_| inside)
.map(|p| CameraEvent::Pan(p - previous));
capture = true;
}
}
Event::Mouse(mouse::Event::WheelScrolled { delta }) if inside && state.focused => {
state.pan = None;
cancel = true;
let y = match delta {
mouse::ScrollDelta::Lines { y, .. } => *y,
mouse::ScrollDelta::Pixels { y, .. } => *y / 40.,
};
if y != 0. {
let at = pointer.unwrap() - Vector::new(layout.bounds().x, layout.bounds().y);
camera = Some(CameraEvent::Zoom {
factor: 1.1_f32.powf(y.clamp(-8., 8.)),
at,
base: self.scale,
});
capture = true;
}
}
Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(keyboard::key::Named::F6),
modifiers,
repeat: false,
..
}) if self.keyboard && modifiers.is_empty() => {
camera = Some(CameraEvent::ToggleFocus);
capture = true;
cancel = true;
}
Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. })
if state.focused && self.keyboard =>
{
use keyboard::{Key, key::Named};
match key.as_ref() {
Key::Named(Named::Space) => {
state.space = true;
cancel = true;
camera = Some(CameraEvent::CancelGesture);
capture = true;
}
Key::Named(Named::Tab) => {
camera = Some(CameraEvent::Traverse(modifiers.shift()));
capture = true;
}
Key::Named(Named::Escape) => {
state.pan = None;
state.space = false;
camera = Some(CameraEvent::Leave);
capture = true;
}
Key::Named(key) if modifiers.is_empty() => {
let delta = match key {
Named::ArrowLeft => Some(Vector::new(60., 0.)),
Named::ArrowRight => Some(Vector::new(-60., 0.)),
Named::ArrowUp => Some(Vector::new(0., 60.)),
Named::ArrowDown => Some(Vector::new(0., -60.)),
_ => None,
};
camera = delta.map(CameraEvent::Pan);
capture = camera.is_some();
cancel = capture;
}
_ => {}
}
}
Event::Keyboard(keyboard::Event::KeyReleased { key, .. })
if *key == keyboard::Key::Named(keyboard::key::Named::Space) =>
{
state.space = false;
}
_ => {}
}
if cancel {
self.content.as_widget_mut().update(
&mut tree.children[0],
&Event::Window(iced::window::Event::Unfocused),
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
}
if let Some(event) = camera {
shell.publish((self.publish)(event));
}
if capture {
shell.capture_event();
shell.request_redraw();
return;
}
if matches!(event, Event::Keyboard(_)) && (!state.focused || !self.keyboard) {
return;
}
let child_cursor = if inside {
pointer.map_or(mouse::Cursor::Unavailable, mouse::Cursor::Available)
} else {
mouse::Cursor::Unavailable
};
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
child_cursor,
renderer,
clipboard,
shell,
viewport,
);
// A semantic hit or armed creation tool gets first refusal. In Select,
// an otherwise unused blank press starts the same camera gesture.
if self.blank_pan
&& self.keyboard
&& inside
&& !shell.is_event_captured()
&& matches!(
event,
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
)
{
state.pan = pointer.map(|p| (mouse::Button::Left, p));
shell.publish((self.publish)(CameraEvent::CancelGesture));
shell.capture_event();
shell.request_redraw();
}
}
fn draw(
&self,
tree: &Tree,
renderer: &mut R,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
renderer.with_layer(layout.bounds(), |renderer| {
self.content.as_widget().draw(
&tree.children[0],
renderer,
theme,
style,
layout,
cursor,
viewport,
);
});
if tree.state.downcast_ref::<Input>().focused && self.keyboard {
renderer.fill_quad(
renderer::Quad {
bounds: layout.bounds(),
border: Border {
color: crate::style::TEAL,
width: 1.,
..Default::default()
},
..Default::default()
},
iced::Color::TRANSPARENT,
);
}
}
fn mouse_interaction(
&self,
tree: &Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &R,
) -> mouse::Interaction {
if tree.state.downcast_ref::<Input>().pan.is_some() {
mouse::Interaction::Grabbing
} else {
self.content.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
}
}
fn overlay<'a>(
&'a mut self,
tree: &'a mut Tree,
layout: Layout<'a>,
renderer: &R,
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'a, Message, Theme, R>> {
self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
impl<'a, Message: 'a, R: renderer::Renderer + 'a> From<Viewport<'a, Message, R>>
for Element<'a, Message, Theme, R>
{
fn from(viewport: Viewport<'a, Message, R>) -> Self {
Element::new(viewport)
}
}
+38 -8
View File
@@ -102,6 +102,7 @@ pub enum CanvasEvent {
Viewport(Context, Size),
FormShown(Context, Id),
Zoom(Context, Option<f32>),
Camera(Context, u64, super::canvas_workspace::Event),
Focus(Context),
Shelf(Context, Shelf),
OpenUse(Context, Id),
@@ -142,6 +143,7 @@ impl CanvasEvent {
| Self::Viewport(context, _)
| Self::FormShown(context, _)
| Self::Zoom(context, _)
| Self::Camera(context, ..)
| Self::Focus(context)
| Self::Shelf(context, _)
| Self::OpenUse(context, _)
@@ -238,13 +240,6 @@ pub(super) struct ScrollRestore {
pub token: Id,
pub offset: iced::Vector,
}
#[derive(Clone, Copy)]
pub(super) struct ScrollReveal {
pub token: Id,
pub slot: Slot,
pub y: f32,
pub padding: f32,
}
#[derive(PartialEq, Eq)]
struct CallChoiceContext {
document: Id,
@@ -296,6 +291,18 @@ struct Draft {
choice_page: usize,
}
impl State {
pub(crate) fn camera_boundary(&self) -> impl PartialEq + use<> {
(
self.canvas_tool,
self.presentation.focused,
self.presentation.shelf,
self.presentation.viewport,
self.presentation.pan,
self.presentation.zoom,
self.presentation.fit,
)
}
pub(super) fn pending_name(&self) -> Option<&str> {
self.draft.as_ref().map(|d| d.name.as_str())
}
@@ -336,9 +343,31 @@ impl State {
pub(crate) fn viewport(&mut self, size: Size) -> bool {
self.presentation.viewport(size)
}
pub(crate) fn camera(&mut self, event: super::canvas_workspace::Event) -> bool {
let p = &mut self.presentation;
super::canvas_workspace::change(&mut p.pan, &mut p.zoom, &mut p.fit, event)
}
pub(crate) fn zoom(&mut self, scale: Option<f32>) -> bool {
self.presentation.zoom(scale)
}
pub(crate) fn camera_zoom(&mut self, p: &Project, owner: Id, zoom: Option<f32>) -> bool {
let size = Scene::sequence(p, owner, owner).size;
let old = self.presentation.scale(size);
if let Some(next) = zoom {
let viewport = self.presentation.viewport.unwrap_or(Size::new(500., 300.));
self.camera(super::canvas_workspace::Event::Zoom {
factor: next / old,
at: Point::new(viewport.width / 2., viewport.height / 2.),
base: old,
})
} else {
self.zoom(None);
let fitted = self.presentation.scale(size);
self.zoom(Some(fitted));
self.presentation.pan = iced::Vector::ZERO;
true
}
}
pub(crate) fn focused(&self) -> bool {
self.presentation.focused
}
@@ -406,7 +435,7 @@ impl State {
owner: frame.owner,
token: Id::new_v4(),
offsets: frame.offsets,
pending: [true; 2],
pending: [false, true],
});
}
pub(super) fn scroll_restore(
@@ -518,6 +547,7 @@ impl State {
self.scroll_offsets[index] = offset;
changed
}
#[cfg(test)]
fn scroll_offset(&self) -> iced::Vector {
// The former page scroll now belongs to a sibling outline shelf.
self.scroll_offsets[0]
@@ -17,6 +17,7 @@ pub(super) struct Presentation {
pub(super) viewport: Option<Size>,
pub(super) focused: bool,
pub(super) shelf: Option<Shelf>,
pub(super) pan: iced::Vector,
pub(super) zoom: Option<f32>,
pub(super) fit: bool,
}
@@ -46,23 +47,19 @@ impl Presentation {
}
fn fit_scale(&self, scene: Size) -> Option<f32> {
let viewport = self.viewport?;
// This is the measured inner Scrollable, never a window-height estimate.
// Leave room for the overlay scrollbars on both axes.
// Fit uses the actual mounted viewport with a small visual gutter.
Some(
((viewport.width - 16.).max(1.) / scene.width.max(1.))
.min((viewport.height - 16.).max(1.) / scene.height.max(1.)),
)
}
fn scale(&self, scene: Size) -> f32 {
pub(super) fn scale(&self, scene: Size) -> f32 {
if self.fit {
self.fit_scale(scene).unwrap_or(1.).clamp(MIN_SCALE, 1.)
} else {
self.zoom.unwrap_or(1.)
}
}
fn fit_limited(&self, scene: Size) -> bool {
self.fit && self.fit_scale(scene).is_some_and(|scale| scale < MIN_SCALE)
}
}
pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a, Message> {
@@ -78,258 +75,182 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
let clean = !app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty();
let mut scene = Scene::sequence(p, owner, app.selected);
let scale = presentation.scale(scene.size);
let limited = presentation.fit_limited(scene.size);
scene.scale = scale;
scene.camera_pan = presentation.pan;
scene.interaction = Some((context, tool, clean));
// The outline is a sibling shelf, not an ancestor of this canvas.
scene.scroll_offset = state.scroll_offset();
scene.pending_placement = state.pending_placement(owner);
let reveal = state.form_session().and_then(|token| {
let (slot, _) = scene.pending_placement?;
let marker = scene.slots.iter().find(|marker| marker.slot == slot)?;
Some(ScrollReveal {
token,
slot,
y: marker.y * scale,
padding: (24. * scale).max(12.),
})
});
let size = Size::new(scene.size.width * scale, scene.size.height * scale);
let canvas = canvas_pane(
context,
state.scroll_restore(context, ScrollArea::Canvas),
reveal,
canvas::Canvas::new(scene)
.width(size.width)
.height(size.height)
.into(),
);
let scope = super::super::canvas_workspace::Scope {
document: context.document,
owner,
revision: context.revision,
epoch: app.camera_epoch,
};
let keyboard = clean && super::super::canvas_workspace::keyboard_enabled(app);
let camera = move |e| Message::ScenarioCanvas(CanvasEvent::Camera(context, scope.epoch, e));
let canvas = container(
sensor(
super::super::canvas_workspace::Viewport::new(
canvas::Canvas::new(scene).width(Fill).height(Fill),
scope,
scale,
keyboard,
camera,
)
.pan_background(tool == CanvasTool::Select),
)
.key((context.document, owner))
.on_show(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size)))
.on_resize(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size))),
)
.id("scenario-viewport")
.width(Fill)
.height(Fill)
.style(style::card)
.into();
let mut navigation = row![
out.width(Fill),
button(
text(if presentation.focused {
"Restore panels"
} else {
"Focus canvas"
})
.size(12)
super::super::canvas_workspace::Control::new(
button(
text(if presentation.focused {
"Restore panels"
} else {
"Focus canvas"
})
.size(12)
)
.padding(6)
.on_press(super::super::canvas_workspace::intent(
scope,
"Panels",
Message::ScenarioCanvas(CanvasEvent::Focus(context))
)),
scope,
"Panels",
keyboard,
Some(super::super::canvas_workspace::intent(
scope,
"Panels",
Message::ScenarioCanvas(CanvasEvent::Focus(context))
)),
camera
)
.padding(6)
.on_press(Message::ScenarioCanvas(CanvasEvent::Focus(context)))
]
.spacing(6)
.align_y(iced::Center);
if state.can_back(p, context) {
navigation = navigation.push(
button(text("Back to caller").size(12))
.padding(6)
.on_press_maybe(
clean.then_some(Message::ScenarioCanvas(CanvasEvent::Back(context))),
),
);
}
let mut header = column![
navigation,
row![
button(text("Tools").size(12))
.padding(6)
.style(if presentation.shelf == Some(Shelf::Tools) {
button::primary
} else {
style::nav
})
.on_press(Message::ScenarioCanvas(CanvasEvent::Shelf(
context,
Shelf::Tools
))),
button(text("Selection").size(12))
.padding(6)
.style(if presentation.shelf == Some(Shelf::Selection) {
button::primary
} else {
style::nav
})
.on_press(Message::ScenarioCanvas(CanvasEvent::Shelf(
context,
Shelf::Selection
))),
button(text("Fit overview").size(12))
.padding(6)
.on_press(Message::ScenarioCanvas(CanvasEvent::Zoom(context, None))),
button(text("−").size(12))
.padding(6)
.on_press(Message::ScenarioCanvas(CanvasEvent::Zoom(
context,
Some(scale / 1.25)
))),
text(format!("{:.0}%", scale * 100.)).size(12),
button(text("+").size(12))
.padding(6)
.on_press(Message::ScenarioCanvas(CanvasEvent::Zoom(
context,
Some(scale * 1.25)
))),
button(text("100%").size(12))
.padding(6)
.on_press(Message::ScenarioCanvas(CanvasEvent::Zoom(
context,
Some(1.)
)))
]
.spacing(6)
.align_y(iced::Center)
.wrap(),
text(if pending {
"Pending form · the highlighted sequence position is retained".to_owned()
} else if !p.children(owner).any(|e| e.kind() == Kind::Lifeline) {
"Start with a lifeline representing existing or explicitly new work / participants."
.into()
} else if !p.children(owner).any(|e| e.kind() == Kind::Occurrence) {
"Choose a first message, action or another tool; no occurrence is selected yet.".into()
} else {
let guidance = match tool {
CanvasTool::Message(_) => {
"Click a source position, then click or drag to a target · Esc cancels"
}
CanvasTool::Add(Tool::Action | Tool::Observation) => {
"Click a highlighted position · a lifeline click preselects that lifeline"
}
CanvasTool::Add(Tool::Reuse | Tool::Fragment) => {
"Click a highlighted sequence position to open its form"
}
_ => "Select a lifeline or occurrence · drag its ↕ handle to reorder",
};
format!("{tool} · {guidance}")
})
.size(11)
]
.spacing(6);
if !pending {
if !p.children(owner).any(|e| e.kind() == Kind::Lifeline) {
header = header.push(
row![
text(format!("No lifelines · {:?}", viewpoint(p, owner))).size(12),
button("Add first lifeline").on_press_maybe(clean.then_some({
Message::ScenarioCanvas(CanvasEvent::First(context, Tool::Lifeline))
}))
]
.spacing(8),
);
} else if !p.children(owner).any(|e| e.kind() == Kind::Occurrence) {
header = header.push(
row![
text("No occurrences yet").size(12),
button("Add first message").on_press_maybe(clean.then_some({
Message::ScenarioCanvas(CanvasEvent::First(context, Tool::Message))
})),
button("Add first action").on_press_maybe(clean.then_some({
Message::ScenarioCanvas(CanvasEvent::First(context, Tool::Action))
}))
]
.spacing(8),
);
}
if state.can_back(p, context) {
navigation =
navigation.push(button(text("Back").size(12)).padding(6).on_press_maybe(
clean.then_some(Message::ScenarioCanvas(CanvasEvent::Back(context))),
));
}
if limited {
header = header
.push(text("Minimum zoom reached · scroll to see the remaining scenario.").size(11));
let header = column![
navigation,
super::super::canvas_workspace::controls(
scope,
keyboard,
scale,
Message::ScenarioCanvas(CanvasEvent::Shelf(context, Shelf::Selection)),
clean.then_some(Message::ScenarioCanvas(CanvasEvent::Camera(
context,
scope.epoch,
super::super::canvas_workspace::Event::Focus
))),
move |zoom| Message::ScenarioCanvas(CanvasEvent::Zoom(context, zoom)),
camera
)
]
.spacing(4);
let entries = [
("S", "Select / reorder occurrences", CanvasTool::Select),
(
"L",
"Lifeline: click to choose represented work or participant",
CanvasTool::Add(Tool::Lifeline),
),
(
"C/R",
"Call and correlated reply",
CanvasTool::Message(MessageStyle::CallReply),
),
(
"M",
"Asynchronous message",
CanvasTool::Message(MessageStyle::Asynchronous),
),
(
"C",
"Synchronous call",
CanvasTool::Message(MessageStyle::Call),
),
(
"R",
"Reply to existing call",
CanvasTool::Message(MessageStyle::Reply),
),
(
"+L",
"Create participant",
CanvasTool::Message(MessageStyle::Create),
),
(
"-L",
"Delete participant",
CanvasTool::Message(MessageStyle::Delete),
),
("T", "Local timer", CanvasTool::Message(MessageStyle::Timer)),
(
"A",
"Action: click a sequence position",
CanvasTool::Add(Tool::Action),
),
(
"St",
"State observation: click a sequence position",
CanvasTool::Add(Tool::Observation),
),
(
"ref",
"Reuse scenario: click a sequence position",
CanvasTool::Add(Tool::Reuse),
),
(
"alt",
"Fragment: click a sequence position",
CanvasTool::Add(Tool::Fragment),
),
];
let palette = super::super::canvas_workspace::palette(
scope,
keyboard,
camera,
entries.into_iter().map(|(glyph, label, choice)| {
(
glyph,
label,
choice == tool,
clean.then_some(Message::ScenarioCanvas(CanvasEvent::Choose(
context, choice,
))),
)
}),
);
let mut details = column![
text(format!("Selected: {}", p.named(app.selected))).size(12),
button(text("Properties").size(12))
.on_press_maybe(clean.then_some(Message::EditProperties(app.selected)))
]
.spacing(6);
if referenced_scenario(p, owner, app.selected).is_some() {
details = details.push(button(text("Open definition").size(12)).on_press_maybe(
clean.then_some(Message::ScenarioCanvas(CanvasEvent::OpenUse(
context,
app.selected,
))),
));
}
let details = match presentation.shelf {
Some(Shelf::Tools) => {
let choose = move |tool| Message::ScenarioCanvas(CanvasEvent::Choose(context, tool));
let tool_picker: Element<'_, Message> = if clean {
pick_list(
vec![
CanvasTool::Select,
CanvasTool::Message(MessageStyle::CallReply),
CanvasTool::Message(MessageStyle::Asynchronous),
CanvasTool::Message(MessageStyle::Call),
CanvasTool::Message(MessageStyle::Reply),
CanvasTool::Message(MessageStyle::Create),
CanvasTool::Message(MessageStyle::Delete),
CanvasTool::Message(MessageStyle::Timer),
CanvasTool::Add(Tool::Action),
CanvasTool::Add(Tool::Observation),
CanvasTool::Add(Tool::Reuse),
CanvasTool::Add(Tool::Fragment),
],
Some(tool),
choose,
)
.text_size(12)
.padding(6)
.width(Fill)
.into()
} else {
button(text(format!("{tool} · finish pending edits to change tools")).size(12))
.padding(6)
.width(Fill)
.into()
};
column![
tool_picker,
row![
button(text("+ Lifeline").size(12))
.padding(6)
.on_press_maybe(
clean.then_some(event(Event::Start(owner, Tool::Lifeline)))
),
button(text("Scenario properties").size(12))
.padding(6)
.on_press_maybe(clean.then_some(Message::EditProperties(owner))),
]
.spacing(6)
.wrap(),
text(match viewpoint(p, owner) {
ScenarioViewpoint::OperationalActivity =>
"Activity scenario · lifelines represent activities",
ScenarioViewpoint::Functional =>
"Functional scenario · lifelines represent system functions",
ScenarioViewpoint::ParticipantInteraction =>
"Participant scenario · entities, actors or roles",
ScenarioViewpoint::BoundaryInterface =>
"Interface scenario · system and external actors",
ScenarioViewpoint::MixedLegacy => "Mixed scenario · work or participants",
})
.size(11),
]
.spacing(6)
}
_ => {
let mut details =
column![text(format!("Selected: {}", p.named(app.selected))).size(12)].spacing(6);
let mut navigation = row![].spacing(6);
if referenced_scenario(p, owner, app.selected).is_some() {
navigation = navigation.push(
button(text("Open definition").size(12))
.padding(6)
.on_press_maybe(clean.then_some(Message::ScenarioCanvas(
CanvasEvent::OpenUse(context, app.selected),
))),
);
}
navigation = navigation.push(
button(text("Properties").size(12))
.padding(6)
.on_press_maybe(clean.then_some(Message::EditProperties(app.selected))),
);
details = details.push(navigation.wrap());
if lines(p, owner).is_empty() && p.elements[&owner].steps().is_none_or(Vec::is_empty) {
details.push(
text("Use Tools to add lifelines, then draw messages or insert occurrences.")
.size(12),
)
} else {
sequence_rows(p, context, owner, details, clean)
}
}
};
let details = details_pane(
context,
state.scroll_restore(context, ScrollArea::Page),
details.into(),
presentation.shelf.is_some(),
pending,
sequence_rows(p, context, owner, details, clean).into(),
);
let mut form_panel = editor_panel(
Id::nil(),
@@ -647,7 +568,12 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
.into();
}
}
workspace_layout(header.into(), details, canvas, form_panel, pending)
let editor = if pending {
Some(form_panel)
} else {
presentation.shelf.map(|_| details)
};
super::super::canvas_workspace::host(header.into(), palette, canvas, editor)
}
fn sequence_action<'a>(
@@ -661,91 +587,23 @@ fn sequence_action<'a>(
.on_press_maybe(enabled.then_some(message))
}
// Permanent header/shelf/body and canvas/form slots. Zero-sized disclosures
// must remain in the tree: Column::push otherwise drops them and remounts siblings.
fn workspace_layout<'a, R: iced::advanced::text::Renderer + 'a>(
header: iced::Element<'a, Message, Theme, R>,
details: iced::Element<'a, Message, Theme, R>,
canvas: iced::Element<'a, Message, Theme, R>,
form: iced::Element<'a, Message, Theme, R>,
pending: bool,
) -> iced::Element<'a, Message, Theme, R> {
let panes = Column::from_vec(vec![
container(canvas).width(Fill).height(Fill).into(),
container(form)
.width(Fill)
.height(if pending {
Fill
} else {
iced::Length::Fixed(0.)
})
.clip(true)
.into(),
])
.spacing(if pending { 6 } else { 0 })
.width(Fill)
.height(Fill);
Column::from_vec(vec![header, details, panes.into()])
.spacing(6)
.padding(8)
.width(Fill)
.height(Fill)
.into()
}
fn details_pane<'a, R: iced::advanced::text::Renderer + 'a>(
context: Context,
restore: Option<ScrollRestore>,
details: iced::Element<'a, Message, Theme, R>,
open: bool,
pending: bool,
) -> iced::Element<'a, Message, Theme, R> {
scenario_scroll::observe(
context,
ScrollArea::Page,
false,
restore,
scrollable(details)
.id("scenario-details")
.width(Fill)
.height(if open {
if pending { 72. } else { 100. }
} else {
0.
}),
.height(Fill),
)
}
fn canvas_pane<'a, R: iced::advanced::text::Renderer + 'a>(
context: Context,
restore: Option<ScrollRestore>,
reveal: Option<ScrollReveal>,
content: iced::Element<'a, Message, Theme, R>,
) -> iced::Element<'a, Message, Theme, R> {
container(
sensor(scenario_scroll::observe_canvas(
context,
restore,
reveal,
scrollable(content)
.id("scenario-canvas")
.direction(scrollable::Direction::Both {
vertical: scrollable::Scrollbar::default(),
horizontal: scrollable::Scrollbar::default(),
})
.width(Fill)
.height(Fill),
))
.key((context.document, context.scenario))
.on_show(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size)))
.on_resize(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size))),
)
.padding(8)
.width(Fill)
.height(Fill)
.style(style::card)
.into()
}
fn editor_panel<'a, R: iced::advanced::text::Renderer + 'a>(
session: Id,
actions: iced::Element<'a, Message, Theme, R>,
File diff suppressed because it is too large Load Diff
+25 -2
View File
@@ -81,9 +81,10 @@ pub struct State {
tool: CanvasTool,
expanded: BTreeSet<Id>,
scroll_offsets: [iced::Vector; 2],
return_to: Vec<(Id, Id, Option<oa_core::topology_context::ContextAddress>)>,
return_to: Vec<direct::ReturnFrame>,
draft: Option<Draft>,
viewport: Option<Size>,
pan: iced::Vector,
zoom: Option<f32>,
fit: bool,
pub(crate) focused: bool,
@@ -125,6 +126,18 @@ struct Draft {
contextual: Option<contextual::Draft>,
}
impl State {
pub(crate) fn camera_boundary(&self) -> impl PartialEq + use<> {
(
self.tool,
self.focused,
self.shelf,
self.viewport,
self.pan,
self.zoom,
self.fit,
)
}
pub(super) fn pending_name(&self) -> Option<&str> {
self.draft.as_ref().map(|d| d.name.as_str())
}
@@ -348,10 +361,12 @@ impl State {
)
}
#[cfg(test)]
fn canvas_offset(&self) -> iced::Vector {
self.scroll_offsets[0]
}
#[cfg(test)]
fn fit_limited(&self, graph: Size, fallback_width: f32) -> bool {
let available = self.fit_area(fallback_width);
self.fit && (graph.width * 0.05 > available.width || graph.height * 0.05 > available.height)
@@ -970,6 +985,8 @@ struct Graph<'a> {
project: &'a Project,
interaction: Option<(Context, CanvasTool, bool)>,
scroll_offset: iced::Vector,
camera_pan: iced::Vector,
pending_insertion: Option<Insertion>,
nodes: Vec<GraphNode>,
edges: Vec<GraphEdge>,
ordered_paths: Vec<OrderedPath>,
@@ -1302,6 +1319,8 @@ impl<'a> Graph<'a> {
project: p,
interaction: None,
scroll_offset: iced::Vector::ZERO,
camera_pan: iced::Vector::ZERO,
pending_insertion: None,
nodes,
edges,
ordered_paths,
@@ -1401,6 +1420,7 @@ impl canvas::Program<Message> for Graph<'_> {
) -> Vec<canvas::Geometry> {
let mut f = Frame::new(renderer, bounds.size());
f.fill_rectangle(Point::ORIGIN, bounds.size(), Color::WHITE);
f.translate(self.camera_pan);
f.scale(self.scale);
for pass in PAINT_PASSES {
match pass {
@@ -1616,7 +1636,10 @@ impl canvas::Program<Message> for Graph<'_> {
cursor: mouse::Cursor,
) -> mouse::Interaction {
if cursor.position_in(bounds).is_some_and(|p| {
let p = Point::new(p.x / self.scale, p.y / self.scale);
let p = Point::new(
(p.x - self.camera_pan.x) / self.scale,
(p.y - self.camera_pan.y) / self.scale,
);
self.nodes.iter().any(|n| n.rect.contains(p))
|| self.ordered_paths.iter().any(|path| {
path.points.windows(2).any(|pair| {
@@ -608,7 +608,11 @@ mod tests {
"Authored endpoint is retained for explicit repair"
);
assert!(state.prepare_pending(&deleted).is_err());
state.remember(owner, reference);
state.remember(
owner,
reference,
super::super::direct::referenced(&p, owner, reference).unwrap(),
);
assert_eq!(state.back(&deleted), Some((owner, reference)));
assert!(state.selected_context.is_none());
state.draft = None;
+136 -27
View File
@@ -60,6 +60,7 @@ pub enum CanvasEvent {
Open(Context, Id),
Back(Context),
Zoom(Context, Option<f32>),
Camera(Context, u64, super::super::canvas_workspace::Event),
Focus(Context),
Viewport(Context, Size),
}
@@ -77,6 +78,7 @@ impl CanvasEvent {
| Self::Back(c)
| Self::AppendExchange(c)
| Self::Zoom(c, ..)
| Self::Camera(c, ..)
| Self::Focus(c)
| Self::Viewport(c, ..) => *c,
Self::Begin { context, .. }
@@ -86,6 +88,19 @@ impl CanvasEvent {
}
}
}
pub(super) struct ReturnFrame {
identity: Option<(Id, Id)>,
owner: Id,
occurrence: Id,
target: Id,
address: Option<oa_core::topology_context::ContextAddress>,
pan: iced::Vector,
zoom: Option<f32>,
fit: bool,
viewport: Option<Size>,
expanded: BTreeSet<Id>,
contextual_expanded: BTreeSet<OccurrenceEndpoint>,
}
impl State {
pub(crate) fn select_context(&mut self, address: oa_core::topology_context::ContextAddress) {
self.selected_context = Some(address);
@@ -138,6 +153,7 @@ impl State {
if self.identity != Some((document, owner)) {
self.identity = Some((document, owner));
self.tool = CanvasTool::Select;
self.pan = iced::Vector::ZERO;
self.zoom = Some(1.);
self.fit = false;
self.viewport = None;
@@ -162,11 +178,21 @@ impl State {
// Choosing how to edit must retain the user's current view.
self.tool = tool;
}
pub(crate) fn camera(&mut self, event: super::super::canvas_workspace::Event) -> bool {
super::super::canvas_workspace::change(&mut self.pan, &mut self.zoom, &mut self.fit, event)
}
pub(crate) fn zoom(&mut self, value: Option<f32>) {
self.fit = value.is_none();
self.zoom = value.map(|v| v.clamp(0.5, 2.));
self.zoom = value.map(|v| v.clamp(0.05, 3.));
}
pub(crate) fn viewport(&mut self, size: Size) -> bool {
if !size.width.is_finite()
|| !size.height.is_finite()
|| size.width <= 0.
|| size.height <= 0.
{
return false;
}
let changed = self.viewport != Some(size);
self.viewport = Some(size);
changed
@@ -188,24 +214,73 @@ impl State {
}
true
}
pub(crate) fn remember(&mut self, owner: Id, occurrence: Id) {
self.return_to
.push((owner, occurrence, self.selected_context.clone()));
pub(crate) fn remember(&mut self, owner: Id, occurrence: Id, target: Id) {
if self.return_to.len() == 32 {
self.return_to.remove(0);
}
self.return_to.push(ReturnFrame {
identity: self.identity,
owner,
occurrence,
target,
address: self.selected_context.clone(),
pan: self.pan,
zoom: self.zoom,
fit: self.fit,
viewport: self.viewport,
expanded: self.expanded.clone(),
contextual_expanded: self.contextual_expanded.clone(),
});
}
pub(crate) fn back(&mut self, p: &Project) -> Option<(Id, Id)> {
while let Some((owner, occurrence, address)) = self.return_to.pop() {
if is_contribution(p, owner, occurrence) {
self.selected_context = address.filter(|address| {
address.owner == owner
&& oa_core::topology_context::TopologyIndex::new(p)
.resolve(owner, &address.endpoint)
.is_ok()
while let Some(frame) = self.return_to.pop() {
if frame.identity.map(|i| i.0) != self.identity.map(|i| i.0) {
continue;
}
let valid = referenced(p, frame.owner, frame.occurrence) == Some(frame.target)
|| if let Some(address) = &frame.address {
oa_core::topology_context::TopologyIndex::new(p)
.resolve(frame.owner, &address.endpoint)
.is_ok_and(|r| r.defining_sequence == frame.target)
} else {
false
};
if valid && is_contribution(p, frame.owner, frame.occurrence) {
self.identity = frame.identity;
self.selected_context = frame.address.filter(|a| {
oa_core::topology_context::TopologyIndex::new(p)
.resolve(frame.owner, &a.endpoint)
.is_ok()
});
return Some((owner, occurrence));
self.pan = frame.pan;
self.zoom = frame.zoom;
self.fit = frame.fit;
self.viewport = frame.viewport;
self.expanded = frame.expanded;
self.contextual_expanded = frame.contextual_expanded;
return Some((frame.owner, frame.occurrence));
}
}
None
}
pub(crate) fn camera_zoom(&mut self, p: &Project, owner: Id, zoom: Option<f32>) {
let graph =
Graph::expanded_qualified(p, owner, owner, &self.expanded, &self.contextual_expanded);
let old = self.scale(graph.size, 500.);
if let Some(next) = zoom {
let size = self.viewport.unwrap_or(Size::new(500., 300.));
self.camera(super::super::canvas_workspace::Event::Zoom {
factor: next / old,
at: Point::new(size.width / 2., size.height / 2.),
base: old,
});
} else {
self.zoom(None);
let fitted = self.scale(graph.size, 500.);
self.zoom(Some(fitted));
self.pan = iced::Vector::ZERO;
}
}
pub(crate) fn created_selection(&self, p: &Project, command: &Command) -> Option<Id> {
let owner = self.pending_owner()?;
if self
@@ -575,9 +650,12 @@ impl Graph<'_> {
}
_ => cursor,
};
let world = cursor
.position_in(bounds)
.map(|p| Point::new(p.x / self.scale, p.y / self.scale));
let world = cursor.position_in(bounds).map(|p| {
Point::new(
(p.x - self.camera_pan.x) / self.scale,
(p.y - self.camera_pan.y) / self.scale,
)
});
let publish = |event| Some(Action::publish(Message::TopologyCanvas(event)).and_capture());
match event {
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) if visible => {
@@ -730,6 +808,22 @@ impl Graph<'_> {
let Some((context, tool, editable)) = self.interaction else {
return;
};
if let Some(insertion) = self.pending_insertion {
if let Some(edge) = insertion
.link
.and_then(|id| self.edges.iter().find(|e| e.id == id))
{
draw_arrow(f, &edge.points, style::TEAL, 3.);
} else if let Some(node) = insertion
.before
.and_then(|id| self.nodes.iter().find(|n| n.id == id))
{
f.stroke(
&Path::rounded_rectangle(node.rect.position(), node.rect.size(), 10.into()),
Stroke::default().with_color(style::TEAL).with_width(3.),
);
}
}
if !editable {
return;
}
@@ -738,7 +832,12 @@ impl Graph<'_> {
.map(|p| p + self.scroll_offset)
.or_else(|| cursor.position())
.filter(|p| bounds.contains(*p))
.map(|p| Point::new((p.x - bounds.x) / self.scale, (p.y - bounds.y) / self.scale));
.map(|p| {
Point::new(
(p.x - bounds.x - self.camera_pan.x) / self.scale,
(p.y - bounds.y - self.camera_pan.y) / self.scale,
)
});
let live =
state.context == Some(context) && state.tool == tool && state.bounds == Some(bounds);
if live && let Some(source) = state.source {
@@ -853,12 +952,7 @@ impl Graph<'_> {
}
impl State {
pub(crate) fn selection_offset(
&self,
p: &Project,
owner: Id,
id: Id,
) -> Option<scrollable::AbsoluteOffset> {
pub(crate) fn reveal_selection(&mut self, p: &Project, owner: Id, id: Id) -> bool {
let graph =
Graph::expanded_qualified(p, owner, id, &self.expanded, &self.contextual_expanded);
let scale = self.scale(graph.size, 500.);
@@ -870,10 +964,25 @@ impl State {
self.qualified_selection(p, owner)
.map_or(n.rect, |a| n.endpoint_rect(&a.endpoint))
})
.or_else(|| graph.edges.iter().find(|e| e.id == id).map(|e| e.label))?;
Some(scrollable::AbsoluteOffset {
x: (rect.x * scale - 12.).max(0.),
y: (rect.y * scale - 12.).max(0.),
})
.or_else(|| graph.edges.iter().find(|e| e.id == id).map(|e| e.label));
let Some(rect) = rect else {
return false;
};
let viewport = self.viewport.unwrap_or(Size::new(500., 300.));
let x = rect.x * scale + self.pan.x;
let y = rect.y * scale + self.pan.y;
let delta = iced::Vector::new(
if x < 12. || x + rect.width * scale > viewport.width - 12. {
12. - x
} else {
0.
},
if y < 12. || y + rect.height * scale > viewport.height - 12. {
12. - y
} else {
0.
},
);
self.camera(super::super::canvas_workspace::Event::Pan(delta))
}
}
@@ -732,7 +732,7 @@ fn direct_process_readable_default_fit_expansion_and_bounded_choices() {
state.zoom(None);
assert!(state.scale(Size::new(3262., 220.), 500.) < 0.2);
state.zoom(Some(0.1));
assert_eq!(state.scale(Size::new(3262., 220.), 500.), 0.5);
assert_eq!(state.scale(Size::new(3262., 220.), 500.), 0.1);
state.enter(Id::new_v4(), f.owner);
assert_eq!(state.scale(Size::new(3262., 220.), 500.), 1.);
let mut all = Vec::new();
@@ -1364,82 +1364,6 @@ fn direct_process_palette_preserves_fit_and_manual_camera() {
}
}
#[test]
fn direct_process_pending_form_diff_retains_the_canvas_scroll_state() {
use iced::advanced::widget::{Tree, tree};
use std::rc::Rc;
fn path_to(tree: &Tree, tag: tree::Tag) -> Option<Vec<usize>> {
if tree.tag == tag {
return Some(Vec::new());
}
tree.children.iter().enumerate().find_map(|(index, child)| {
let mut path = path_to(child, tag)?;
path.insert(0, index);
Some(path)
})
}
fn at<'a>(mut tree: &'a mut Tree, path: &[usize]) -> &'a mut Tree {
for &index in path {
tree = &mut tree.children[index];
}
tree
}
for layer in [Layer::Oa, Layer::Sa] {
for size in [Size::new(1440., 900.), Size::new(1100., 700.)] {
for tool in [Tool::Control, Tool::Branch, Tool::Connect] {
let f = fixture(layer);
let mut app = App::with_project(f.p.clone());
let _ = app.update(Message::Select(f.nodes[1]));
let _ = app.update(Message::Page(crate::app::Page::Behavior));
app.window_size = size;
let original = app.document.snapshot();
let graph: Element<'_, Message> =
canvas::Canvas::new(Graph::new(app.project(), f.owner, f.nodes[1])).into();
let canvas_tag = Tree::new(graph.as_widget()).tag;
drop(graph);
let scroll: Element<'_, Message> = scrollable(text("")).into();
let scroll_tag = Tree::new(scroll.as_widget()).tag;
let mut tree = Tree::new(view::view(&app, f.owner, Column::new()).as_widget());
let mut scroll_path = path_to(&tree, canvas_tag).unwrap();
scroll_path.pop();
assert_eq!(at(&mut tree, &scroll_path).tag, scroll_tag);
// Scrollable::diff treats its local state as opaque. A retained marker
// proves the real Iced diff preserves the state that owns the offsets.
// No layout or input operation runs on this test-only marker state.
let marker = Rc::new(());
at(&mut tree, &scroll_path).state = tree::State::new(Rc::clone(&marker));
let _ = app.update(Message::Behavior(super::super::Event::Topology(
Event::Start(f.owner, tool, Some(f.nodes[1])),
)));
assert!(app.behavior.has_pending());
tree.diff(view::view(&app, f.owner, Column::new()).as_widget());
assert_eq!(
Rc::strong_count(&marker),
2,
"form open: {layer:?} {size:?} {tool:?}"
);
let mut current_path = path_to(&tree, canvas_tag).unwrap();
current_path.pop();
assert_eq!(current_path, scroll_path);
assert!(Rc::ptr_eq(
at(&mut tree, &scroll_path).state.downcast_ref::<Rc<()>>(),
&marker,
));
let _ = app.update(Message::Behavior(super::super::Event::Topology(
Event::Cancel,
)));
tree.diff(view::view(&app, f.owner, Column::new()).as_widget());
assert_eq!(
Rc::strong_count(&marker),
2,
"form cancel: {layer:?} {size:?} {tool:?}"
);
assert!(std::sync::Arc::ptr_eq(&app.document.snapshot(), &original));
}
}
}
}
#[test]
fn direct_process_scaled_control_and_link_gestures_use_visible_positions() {
for layer in [Layer::Oa, Layer::Sa] {
File diff suppressed because it is too large Load Diff
+189 -299
View File
@@ -66,107 +66,7 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
let pending = state.draft.is_some();
let ordered = !legacy_order(p, owner).is_empty();
let selected = is_contribution(p, owner, app.selected).then_some(app.selected);
let choose = |tool| Message::TopologyCanvas(CanvasEvent::Choose(context, tool));
let mut controls = Column::new().spacing(6);
if !pending {
controls = controls
.push(
row![
button("Select / reorder")
.style(if state.tool == CanvasTool::Select {
button::primary
} else {
style::nav
})
.on_press(choose(CanvasTool::Select)),
button("Work")
.style(
if state.tool == CanvasTool::Add(Tool::Work, ControlKind::Choice) {
button::primary
} else {
style::nav
}
)
.on_press(choose(CanvasTool::Add(Tool::Work, ControlKind::Choice))),
button("Exchange")
.style(
if state.tool == CanvasTool::Add(Tool::Exchange, ControlKind::Choice) {
button::primary
} else {
style::nav
}
)
.on_press(choose(CanvasTool::Add(Tool::Exchange, ControlKind::Choice))),
button("Reuse")
.style(
if state.tool == CanvasTool::Add(Tool::Reuse, ControlKind::Choice) {
button::primary
} else {
style::nav
}
)
.on_press(choose(CanvasTool::Add(Tool::Reuse, ControlKind::Choice)))
]
.spacing(6),
)
.push(
row![
pick_list(
vec![
ControlKind::Choice,
ControlKind::Merge,
ControlKind::Fork,
ControlKind::Join,
ControlKind::Loop
]
.into_iter()
.map(ControlChoice)
.collect::<Vec<_>>(),
if let CanvasTool::Add(Tool::Control, kind) = state.tool {
Some(ControlChoice(kind))
} else {
None
},
move |c| Message::TopologyCanvas(CanvasEvent::Choose(
context,
CanvasTool::Add(Tool::Control, c.0)
))
)
.placeholder("Draw control…")
.text_size(12)
.padding(8),
button("Sequence link")
.style(if state.tool == CanvasTool::Connect {
button::primary
} else {
style::nav
})
.on_press(choose(CanvasTool::Connect)),
button("Two paths…").on_press(event(Event::Start(
owner,
Tool::Branch,
selected
)))
]
.spacing(6),
);
}
controls=controls.push(label(if pending{"Pending contribution · apply or cancel below"}else if ordered{"Ordered exchange path · click a path to insert or empty canvas to continue; the edit preserves its order"}else{match state.tool{
CanvasTool::Select=>"Select local contributions and paths · drag path endpoints to reconnect · ↕ reorders within a column",
CanvasTool::Connect=>"Sequence link · click or drag from preceding to following contribution · Escape cancels",
CanvasTool::Add(..)=>"Choose a highlighted path or contribution position · existing work stays shared · Escape cancels"}}));
if !pending && ordered {
controls = controls.push(button("Append exchange…").on_press(Message::TopologyCanvas(
CanvasEvent::AppendExchange(context),
)));
let commands = preserved_order(p, owner, None);
if !commands.is_empty() {
controls = controls.push(
button("Edit path links and guards")
.on_press(Message::Command(unique_batch(p, commands))),
);
}
}
let clean = !app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty();
// A fixed sibling keeps Iced's canvas scroll state through selection/form changes.
let mut navigation = column![].spacing(6);
if let Some(Relation {
@@ -211,11 +111,6 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
);
}
}
if !pending {
controls = controls.push(button("Qualified link / assembly…").on_press(event(
Event::Start(owner, Tool::Contextual, Some(app.selected)),
)));
}
if let Some(id) = selected {
let mut actions =
row![button("Contribution properties").on_press(Message::EditProperties(id))]
@@ -256,27 +151,79 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
button("Back to caller").on_press(Message::TopologyCanvas(CanvasEvent::Back(context))),
);
}
let diagram = {
let mut graph = Graph::expanded_qualified(
p,
owner,
app.selected,
&state.expanded,
&state.contextual_expanded,
navigation = navigation
.push(
button("Two paths...").on_press_maybe(clean.then_some(event(Event::Start(
owner,
Tool::Branch,
selected,
)))),
)
.push(
button("Qualified link / assembly...").on_press_maybe(clean.then_some(event(
Event::Start(owner, Tool::Contextual, Some(app.selected)),
))),
);
graph.scale = state.scale(graph.size, (app.window_size.width - 600.).max(200.));
graph.interaction = Some((
context,
state.tool,
!app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty(),
));
// Explicit graphs no longer have a scrolling page ancestor. A retained
// offset from an ordered view must not distort their pointer coordinates.
graph.scroll_offset = state.canvas_offset();
let scale = graph.scale;
let mut header = column![
row![
out.width(Fill),
if ordered {
navigation = navigation.push(button("Append exchange...").on_press_maybe(clean.then_some(
Message::TopologyCanvas(CanvasEvent::AppendExchange(context)),
)));
let commands = preserved_order(p, owner, None);
if !commands.is_empty() {
navigation = navigation.push(
button("Edit path links and guards")
.on_press_maybe(clean.then_some(Message::Command(unique_batch(p, commands)))),
);
}
}
let mut graph = Graph::expanded_qualified(
p,
owner,
app.selected,
&state.expanded,
&state.contextual_expanded,
);
graph.scale = state.scale(graph.size, (app.window_size.width - 600.).max(200.));
graph.camera_pan = state.pan;
graph.pending_insertion = state
.draft
.as_ref()
.filter(|d| d.owner == owner && d.fixed)
.map(|d| d.insertion);
graph.interaction = Some((context, state.tool, clean));
let scale = graph.scale;
let scope = super::super::canvas_workspace::Scope {
document: context.document,
owner,
revision: context.revision,
epoch: app.camera_epoch,
};
let keyboard = clean && super::super::canvas_workspace::keyboard_enabled(app);
let camera = move |e| Message::TopologyCanvas(CanvasEvent::Camera(context, scope.epoch, e));
let canvas = container(
iced::widget::sensor(
super::super::canvas_workspace::Viewport::new(
canvas::Canvas::new(graph).width(Fill).height(Fill),
scope,
scale,
keyboard,
camera,
)
.pan_background(state.tool == CanvasTool::Select),
)
.key((context.document, owner))
.on_show(move |size| Message::TopologyCanvas(CanvasEvent::Viewport(context, size)))
.on_resize(move |size| Message::TopologyCanvas(CanvasEvent::Viewport(context, size))),
)
.id("process-viewport")
.width(Fill)
.height(Fill)
.style(style::card)
.into();
let header = column![
row![
out.width(Fill),
super::super::canvas_workspace::Control::new(
button(
text(if state.focused {
"Restore panels"
@@ -285,121 +232,104 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
})
.size(12)
)
.on_press(Message::TopologyCanvas(CanvasEvent::Focus(context)))
.padding(6)
]
.spacing(6)
.align_y(iced::Center),
row![
button(text("Tools").size(12))
.padding(6)
.style(if state.shelf == Some(Shelf::Tools) {
button::primary
} else {
style::nav
})
.on_press(event(Event::Shelf(Shelf::Tools))),
button(text("Selection details").size(12))
.padding(6)
.style(if state.shelf == Some(Shelf::Selection) {
button::primary
} else {
style::nav
})
.on_press(event(Event::Shelf(Shelf::Selection))),
button(text("Fit overview").size(12))
.padding(6)
.on_press(Message::TopologyCanvas(CanvasEvent::Zoom(context, None))),
button(text("−").size(12))
.padding(6)
.on_press(Message::TopologyCanvas(CanvasEvent::Zoom(
context,
Some(scale / 1.25)
))),
label(format!("{:.0}%", scale * 100.)),
button(text("+").size(12))
.padding(6)
.on_press(Message::TopologyCanvas(CanvasEvent::Zoom(
context,
Some(scale * 1.25)
))),
button(text("100%").size(12))
.padding(6)
.on_press(Message::TopologyCanvas(CanvasEvent::Zoom(
context,
Some(1.)
)))
]
.spacing(6)
.wrap(),
.on_press(super::super::canvas_workspace::intent(
scope,
"Panels",
Message::TopologyCanvas(CanvasEvent::Focus(context))
)),
scope,
"Panels",
keyboard,
Some(super::super::canvas_workspace::intent(
scope,
"Panels",
Message::TopologyCanvas(CanvasEvent::Focus(context))
)),
camera
)
]
.spacing(6);
if state.fit_limited(graph.size, (app.window_size.width - 600.).max(200.)) {
header = header
.push(text("Minimum zoom reached · scroll to see the remaining graph.").size(11));
}
if graph.nodes.is_empty() {
controls = controls.push(label(
"Choose Work, Exchange, Reuse or a control, then click the canvas to start.",
));
}
let size = Size::new(graph.size.width * scale, graph.size.height * scale);
let canvas = scrollable(
canvas::Canvas::new(graph)
.width(size.width)
.height(size.height),
)
.id("process-canvas")
.direction(scrollable::Direction::Both {
vertical: scrollable::Scrollbar::default(),
horizontal: scrollable::Scrollbar::default(),
})
.width(Fill)
.height(Fill);
let canvas = container(
iced::widget::sensor(super::super::scenario_scroll::observe_topology(
.spacing(6),
super::super::canvas_workspace::controls(
scope,
keyboard,
scale,
event(Event::Shelf(Shelf::Selection)),
clean.then_some(Message::TopologyCanvas(CanvasEvent::Camera(
context,
scenario::ScrollArea::Canvas,
canvas,
))
.on_show(move |size| Message::TopologyCanvas(CanvasEvent::Viewport(context, size)))
.on_resize(move |size| Message::TopologyCanvas(CanvasEvent::Viewport(context, size))),
scope.epoch,
super::super::canvas_workspace::Event::Focus
))),
move |zoom| Message::TopologyCanvas(CanvasEvent::Zoom(context, zoom)),
camera
)
.padding(8)
.width(Fill)
.height(Fill)
.style(style::card);
if !pending && !p.children(owner).any(|e| is_contribution(p, owner, e.id)) {
let title = if p.layer_of(owner) == Some(Layer::Oa) {
"Add first activity contribution"
} else {
"Add first function contribution"
};
header = header.push(button(title).on_press_maybe(
(!app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty()).then(
|| {
Message::TopologyCanvas(CanvasEvent::Begin {
context,
tool: Tool::Work,
control: ControlKind::Choice,
insertion: Insertion::default(),
})
},
),
));
}
let details: Element<'_, Message> = match state.shelf {
Some(Shelf::Tools) => controls.into(),
_ => navigation.push(text("Full qualified identities and definition actions appear here for the selected contribution or link.").size(11)).into(),
};
explicit_pane(
header.into(),
details,
canvas.into(),
state.shelf.is_some(),
pending,
)
};
]
.spacing(4);
let entries = [
("S", "Select contributions or paths", CanvasTool::Select),
(
"W",
"Work: click a path or empty space",
CanvasTool::Add(Tool::Work, ControlKind::Choice),
),
(
"E",
"Exchange: click a path or empty space",
CanvasTool::Add(Tool::Exchange, ControlKind::Choice),
),
(
"ref",
"Reuse process / chain",
CanvasTool::Add(Tool::Reuse, ControlKind::Choice),
),
(
"OR",
"Choice",
CanvasTool::Add(Tool::Control, ControlKind::Choice),
),
(
"M",
"Merge",
CanvasTool::Add(Tool::Control, ControlKind::Merge),
),
(
"AND",
"Fork",
CanvasTool::Add(Tool::Control, ControlKind::Fork),
),
(
"J",
"Join",
CanvasTool::Add(Tool::Control, ControlKind::Join),
),
(
"IT",
"Loop",
CanvasTool::Add(Tool::Control, ControlKind::Loop),
),
(
"->",
"Sequence link: click or drag between contributions",
CanvasTool::Connect,
),
];
let palette = super::super::canvas_workspace::palette(
scope,
keyboard,
camera,
entries.into_iter().map(|(glyph, label, choice)| {
(
glyph,
label,
choice == state.tool,
clean.then_some(Message::TopologyCanvas(CanvasEvent::Choose(
context, choice,
))),
)
}),
);
let details = scrollable(navigation.push(text("Layout is derived. The reorder handle changes display order within its column; it does not author a position.").size(11)))
.id("process-details").width(Fill).height(Fill).into();
let mut form_panel: Element<'_, Message> = iced::widget::Space::new().height(0).into();
if let Some(d) = state.draft.as_ref().filter(|d| d.owner == owner) {
if let Some(child) = d.source.view(p) {
@@ -617,77 +547,37 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
button("Cancel").on_press(event(Event::Cancel))
]
.spacing(7);
form_panel = editor_panel(actions.into(), form.into());
form_panel = editor_panel(d.invocation, actions.into(), form.into());
}
}
panes(diagram, form_panel, pending)
}
/// A permanent three-slot tree: variable controls never remount the canvas.
/// The shelf scrolls independently; the canvas receives actual remaining height.
fn explicit_pane<'a, R: iced::advanced::text::Renderer + 'a>(
header: iced::Element<'a, Message, iced::Theme, R>,
details: iced::Element<'a, Message, iced::Theme, R>,
canvas: iced::Element<'a, Message, iced::Theme, R>,
show_details: bool,
pending: bool,
) -> iced::Element<'a, Message, iced::Theme, R> {
// Column::push drops Fixed(0) children. from_vec retains the closed shelf
// slot so its disclosure cannot move/remount the following canvas tree.
Column::from_vec(vec![
container(header).width(Fill).into(),
scrollable(details)
.id("process-details")
.width(Fill)
.height(if show_details {
if pending { 64. } else { 112. }
} else {
0.
})
.into(),
container(canvas).width(Fill).height(Fill).into(),
])
.spacing(6)
.padding(8)
.width(Fill)
.height(Fill)
.into()
}
/// Keep the diagram mounted while an independent editor uses the remaining
/// workspace height. The editor must never be clipped by the page scrollable.
fn panes<'a, R: iced::advanced::Renderer + 'a>(
diagram: iced::Element<'a, Message, iced::Theme, R>,
form: iced::Element<'a, Message, iced::Theme, R>,
pending: bool,
) -> iced::Element<'a, Message, iced::Theme, R> {
column![
container(diagram).width(Fill).height(if pending {
iced::Length::FillPortion(1)
} else {
Fill
}),
container(form).width(Fill).height(if pending {
iced::Length::FillPortion(1)
} else {
iced::Length::Shrink
}),
]
.width(Fill)
.height(Fill)
.into()
let editor = if pending {
Some(form_panel)
} else {
state.shelf.map(|_| details)
};
super::super::canvas_workspace::host(header.into(), palette, canvas, editor)
}
fn editor_panel<'a, R: iced::advanced::text::Renderer + 'a>(
session: Id,
actions: iced::Element<'a, Message, iced::Theme, R>,
fields: iced::Element<'a, Message, iced::Theme, R>,
) -> iced::Element<'a, Message, iced::Theme, R> {
container(
container(column![actions, scrollable(fields).id("process-form").height(Fill)].spacing(8))
.width(Fill)
.height(Fill)
.padding(12)
.style(style::card),
container(
column![
container(actions).id("process-actions"),
super::super::form_session::reset_on_change(
session,
scrollable(fields).id("process-form").height(Fill)
)
]
.spacing(8),
)
.width(Fill)
.height(Fill)
.padding(12)
.style(style::card),
)
.width(Fill)
.height(Fill)