Integrate shared behavior canvas with current form focus and recovery
This commit is contained in:
@@ -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(()) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user