Compose saved Scenario authoring with current Text and input authority
This commit is contained in:
@@ -13,13 +13,14 @@ pub enum Shelf {
|
||||
#[derive(Clone, Default)]
|
||||
pub(super) struct Presentation {
|
||||
pub(super) identity: Option<(Id, Id)>,
|
||||
pub(super) representation: Option<Representation>,
|
||||
pub(super) visit: Id,
|
||||
pub(super) viewport: Option<Size>,
|
||||
pub(super) shelf: Option<Shelf>,
|
||||
pub(super) pan: iced::Vector,
|
||||
pub(super) zoom: Option<f32>,
|
||||
pub(super) fit: bool,
|
||||
}
|
||||
const MIN_SCALE: f32 = 0.05;
|
||||
impl Presentation {
|
||||
pub(super) fn viewport(&mut self, size: Size) -> bool {
|
||||
if !size.width.is_finite()
|
||||
@@ -34,29 +35,26 @@ impl Presentation {
|
||||
true
|
||||
}
|
||||
pub(super) fn zoom(&mut self, scale: Option<f32>) -> bool {
|
||||
if scale.is_some_and(|value| !value.is_finite() || value <= 0.) {
|
||||
return false;
|
||||
}
|
||||
let next = scale.map(|value| value.clamp(MIN_SCALE, 3.));
|
||||
let changed = self.zoom != next || self.fit != scale.is_none();
|
||||
self.zoom = next;
|
||||
self.fit = scale.is_none();
|
||||
changed
|
||||
camera::set_zoom(&mut self.pan, &mut self.zoom, &mut self.fit, scale)
|
||||
}
|
||||
fn fit_scale(&self, scene: Size) -> Option<f32> {
|
||||
let viewport = self.viewport?;
|
||||
// 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.)),
|
||||
)
|
||||
pub(super) fn camera(&self, bounds: Rectangle) -> camera::Camera {
|
||||
camera::effective_camera(self.pan, self.zoom, self.fit, self.viewport, bounds)
|
||||
}
|
||||
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.)
|
||||
}
|
||||
// Legacy tests call size-only scale; actual display always passes full bounds.
|
||||
#[cfg(test)]
|
||||
pub(super) fn scale(&self, size: Size) -> f32 {
|
||||
self.camera(Rectangle::new(Point::ORIGIN, size)).scale
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct ArrangementChoice {
|
||||
representation: Representation,
|
||||
label: String,
|
||||
}
|
||||
impl std::fmt::Display for ArrangementChoice {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.label)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,43 +66,136 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
||||
.draft
|
||||
.as_ref()
|
||||
.is_some_and(|draft| draft.owner == owner);
|
||||
let context = app.scenario_context().expect("visible scenario context");
|
||||
let Some(context) = app.scenario_context() else {
|
||||
return column![
|
||||
out,
|
||||
text("Scenario view is unavailable."),
|
||||
button("Open selected source").on_press(Message::SourceForSelection)
|
||||
]
|
||||
.spacing(8)
|
||||
.into();
|
||||
};
|
||||
let tool = state.canvas_tool(owner);
|
||||
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);
|
||||
scene.scale = scale;
|
||||
scene.camera_pan = presentation.pan;
|
||||
scene.interaction = Some((context, tool, clean));
|
||||
scene.pending_placement = state.pending_placement(owner);
|
||||
// Resolve once per accepted view. An active spacing session owns its exact
|
||||
// prepared geometry; borrowing it also retains the canvas input state.
|
||||
let preview = state.spacing_paint(context, app.camera_epoch);
|
||||
let shown = preview
|
||||
.is_none()
|
||||
.then(|| presentation::present(p, context.representation, app.selected));
|
||||
let capabilities = shown.as_ref().map_or(
|
||||
presentation::PresentationCapabilities {
|
||||
geometry_navigation: true,
|
||||
coordinate_input: true,
|
||||
},
|
||||
ScenarioPresentation::capabilities,
|
||||
);
|
||||
let coordinate_input = clean && capabilities.coordinate_input;
|
||||
let notice = if let Some(paint) = &preview {
|
||||
display_notice_parts(
|
||||
p,
|
||||
context.representation,
|
||||
&presentation::PresentationStatus::Ready,
|
||||
paint.original_diagnostics,
|
||||
paint.original_latent,
|
||||
)
|
||||
} else {
|
||||
display_notice(p, shown.as_ref().expect("normal presentation"))
|
||||
};
|
||||
let camera_state = preview
|
||||
.as_ref()
|
||||
.map(|paint| camera::Camera {
|
||||
scale: paint.scene.scale,
|
||||
pan: paint.scene.camera_pan,
|
||||
})
|
||||
.or_else(|| {
|
||||
shown
|
||||
.as_ref()
|
||||
.and_then(ScenarioPresentation::bounds)
|
||||
.map(|bounds| presentation.camera(bounds))
|
||||
});
|
||||
let scale = camera_state.map_or(1., |camera| camera.scale);
|
||||
let scope = super::super::canvas_workspace::Scope {
|
||||
document: context.document,
|
||||
owner,
|
||||
revision: context.revision,
|
||||
epoch: app.camera_epoch,
|
||||
scenario_view: Some((context.representation, context.visit)),
|
||||
};
|
||||
let keyboard = clean && super::super::canvas_workspace::keyboard_enabled(app);
|
||||
let keyboard = clean
|
||||
&& capabilities.geometry_navigation
|
||||
&& 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),
|
||||
let raw_canvas: Option<Element<'a, Message>> = if let Some(paint) = &preview {
|
||||
Some(
|
||||
canvas::Canvas::new(paint.scene)
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.into(),
|
||||
)
|
||||
.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();
|
||||
} else if let Some(mut scene) = shown.and_then(ScenarioPresentation::into_scene) {
|
||||
let camera_state = camera_state.expect("displayed Scene has complete bounds");
|
||||
scene.scale = camera_state.scale;
|
||||
scene.camera_pan = camera_state.pan;
|
||||
scene.interaction = Some((context, tool, clean));
|
||||
scene.spacing_stamp = (coordinate_input
|
||||
&& !app.source_rename.open()
|
||||
&& keyboard
|
||||
&& matches!(context.representation, Representation::Saved { .. }))
|
||||
.then_some(spacing::Stamp {
|
||||
context,
|
||||
camera_epoch: app.camera_epoch,
|
||||
});
|
||||
scene.coordinate_input = capabilities.coordinate_input;
|
||||
scene.pending_placement = coordinate_input
|
||||
.then(|| state.pending_placement(owner))
|
||||
.flatten();
|
||||
Some(canvas::Canvas::new(scene).width(Fill).height(Fill).into())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let canvas: Element<'a, Message> = if let Some(raw_canvas) = raw_canvas {
|
||||
// Keep this Stack in both branches: replacing the Canvas with a wrapper
|
||||
// on Begin would discard its pointer capture before Move/Finish.
|
||||
let mut layers = iced::widget::stack![raw_canvas];
|
||||
if let Some(paint) = &preview {
|
||||
let feedback = spacing_feedback(p, paint.refusal, paint.diagnostics);
|
||||
layers = layers.push(
|
||||
container(
|
||||
container(text(feedback).size(12))
|
||||
.padding(6)
|
||||
.width(Fill)
|
||||
.style(style::card),
|
||||
)
|
||||
.width(Fill)
|
||||
.align_bottom(Fill)
|
||||
.padding(8),
|
||||
);
|
||||
}
|
||||
container(
|
||||
sensor(
|
||||
super::super::canvas_workspace::Viewport::new(
|
||||
layers, scope, scale, keyboard, camera,
|
||||
)
|
||||
.pan_background(tool == CanvasTool::Select || !capabilities.coordinate_input),
|
||||
)
|
||||
.key((
|
||||
context.document,
|
||||
format!("{:?}", context.representation),
|
||||
context.visit,
|
||||
))
|
||||
.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()
|
||||
} else {
|
||||
container(text("No supported canvas geometry is available. Inspect the model or open its source to repair this view."))
|
||||
.id("scenario-viewport-unavailable").padding(16).width(Fill).height(Fill).style(style::card).into()
|
||||
};
|
||||
let mut navigation = row![out.width(Fill)].spacing(6);
|
||||
if state.can_back(p, context) {
|
||||
navigation =
|
||||
@@ -112,20 +203,72 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
||||
clean.then_some(Message::ScenarioCanvas(CanvasEvent::Back(context))),
|
||||
));
|
||||
}
|
||||
let header = column![
|
||||
navigation,
|
||||
super::super::canvas_workspace::controls(
|
||||
let mut header = column![navigation, notice].spacing(4);
|
||||
let saved_views = p
|
||||
.diagrams
|
||||
.values()
|
||||
.filter(|diagram| {
|
||||
presentation::checked_owner(
|
||||
p,
|
||||
Representation::Saved {
|
||||
diagram: diagram.id,
|
||||
},
|
||||
) == Some(owner)
|
||||
})
|
||||
.map(|diagram| ArrangementChoice {
|
||||
representation: Representation::Saved {
|
||||
diagram: diagram.id,
|
||||
},
|
||||
label: short(&diagram.name, 64),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if !saved_views.is_empty() {
|
||||
let mut choices = vec![ArrangementChoice {
|
||||
representation: Representation::Derived { owner },
|
||||
label: "Automatic arrangement".into(),
|
||||
}];
|
||||
choices.extend(saved_views);
|
||||
let selected = choices
|
||||
.iter()
|
||||
.find(|choice| choice.representation == context.representation)
|
||||
.cloned();
|
||||
header = header.push(
|
||||
container(
|
||||
pick_list(choices, selected, move |choice: ArrangementChoice| {
|
||||
Message::ScenarioCanvas(CanvasEvent::Representation(
|
||||
context,
|
||||
choice.representation,
|
||||
))
|
||||
})
|
||||
.placeholder("Choose arrangement")
|
||||
.text_size(12)
|
||||
.padding(5)
|
||||
.width(Fill),
|
||||
)
|
||||
.width(Fill)
|
||||
.id("scenario-arrangement-picker"),
|
||||
);
|
||||
}
|
||||
if capabilities.geometry_navigation {
|
||||
header = header.push(super::super::canvas_workspace::controls(
|
||||
scope,
|
||||
keyboard,
|
||||
scale,
|
||||
Message::ScenarioCanvas(CanvasEvent::Shelf(context, Shelf::Selection)),
|
||||
move |zoom| Message::ScenarioCanvas(CanvasEvent::Zoom(context, zoom)),
|
||||
camera
|
||||
)
|
||||
]
|
||||
.spacing(4);
|
||||
camera,
|
||||
));
|
||||
}
|
||||
let entries = [
|
||||
("S", "Select / reorder occurrences", CanvasTool::Select),
|
||||
(
|
||||
"S",
|
||||
if matches!(context.representation, Representation::Saved { .. }) {
|
||||
"Select / move messages and lifelines"
|
||||
} else {
|
||||
"Select / reorder occurrences"
|
||||
},
|
||||
CanvasTool::Select,
|
||||
),
|
||||
(
|
||||
"L",
|
||||
"Lifeline: click to choose represented work or participant",
|
||||
@@ -192,16 +335,21 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
||||
glyph,
|
||||
label,
|
||||
choice == tool,
|
||||
clean.then_some(Message::ScenarioCanvas(CanvasEvent::Choose(
|
||||
context, choice,
|
||||
))),
|
||||
(clean && (choice == CanvasTool::Select || capabilities.coordinate_input))
|
||||
.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)))
|
||||
container(button(text("Properties").size(12)).on_press_maybe(
|
||||
(clean && properties_target(p, context, app.selected)).then_some(
|
||||
Message::ScenarioCanvas(CanvasEvent::Properties(context, app.selected)),
|
||||
),
|
||||
))
|
||||
.id("scenario-properties")
|
||||
]
|
||||
.spacing(6);
|
||||
if referenced_scenario(p, owner, app.selected).is_some() {
|
||||
@@ -212,6 +360,10 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
||||
))),
|
||||
));
|
||||
}
|
||||
details = details.push(text("Scenario order").size(13));
|
||||
if matches!(context.representation, Representation::Saved { .. }) {
|
||||
details = details.push(text("↑ and ↓ change the shared Scenario order. Saved message positions are retained; automatically placed items may move.").size(11));
|
||||
}
|
||||
let details = details_pane(
|
||||
context,
|
||||
state.scroll_restore(context, ScrollArea::Page),
|
||||
@@ -541,6 +693,153 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
||||
super::super::canvas_workspace::host(header.into(), palette, canvas, editor)
|
||||
}
|
||||
|
||||
fn display_notice<'a>(p: &Project, shown: &ScenarioPresentation) -> Element<'a, Message> {
|
||||
display_notice_parts(
|
||||
p,
|
||||
shown.requested(),
|
||||
shown.status(),
|
||||
shown.diagnostics(),
|
||||
shown.latent_messages(),
|
||||
)
|
||||
}
|
||||
|
||||
fn display_notice_parts<'a>(
|
||||
p: &Project,
|
||||
requested: Representation,
|
||||
status: &presentation::PresentationStatus,
|
||||
diagnostics: &[super::super::scenario_layout::Diagnostic],
|
||||
latent_messages: &[Id],
|
||||
) -> Element<'a, Message> {
|
||||
use super::super::scenario_layout::{Diagnostic, presentation::PresentationStatus};
|
||||
let title = match requested {
|
||||
Representation::Saved { diagram } => p.diagrams.get(&diagram).map_or_else(
|
||||
|| "Unavailable saved Scenario view".into(),
|
||||
|view| format!("Saved view · {}", view.name),
|
||||
),
|
||||
Representation::Derived { owner } => format!("Automatic view · {}", p.named(owner)),
|
||||
};
|
||||
let mut notice = column![text(title).size(14)].spacing(4);
|
||||
match status {
|
||||
PresentationStatus::Ready => {
|
||||
for diagnostic in diagnostics.iter().take(3) {
|
||||
let explanation = match diagnostic {
|
||||
Diagnostic::SourceOrderCrossing { before, after, .. } => format!(
|
||||
"Saved spacing places '{}' below '{}'. The Scenario order is unchanged.",
|
||||
p.named(*before),
|
||||
p.named(*after)
|
||||
),
|
||||
Diagnostic::FrameExtendsPastFollowingAnchor { frame, following } => format!(
|
||||
"Saved spacing makes '{}' overlap '{}'. Both keep their saved positions.",
|
||||
p.named(*frame),
|
||||
p.named(*following)
|
||||
),
|
||||
};
|
||||
notice = notice.push(text(explanation).size(12));
|
||||
}
|
||||
if diagnostics.len() > 3 {
|
||||
notice = notice.push(
|
||||
text(format!(
|
||||
"{} additional spacing notices are retained.",
|
||||
diagnostics.len() - 3
|
||||
))
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
}
|
||||
PresentationStatus::Fallback { requested_issues } => {
|
||||
notice = notice.push(text("Showing the complete automatic arrangement because the saved positions cannot currently be displayed. Saved positions are preserved.").size(12));
|
||||
if let Some(issue) = requested_issues.first() {
|
||||
notice = notice.push(text(geometry_problem(p, issue)).size(12));
|
||||
}
|
||||
notice = notice.push(text("Selection, navigation and Fit remain available. Canvas insertion and reordering are unavailable until the saved positions are repaired.").size(12));
|
||||
}
|
||||
PresentationStatus::Unavailable { .. } => {
|
||||
notice = notice.push(text("This view is unavailable. Its identity or automatic geometry cannot be resolved; the model and source remain available for inspection and repair.").size(12));
|
||||
}
|
||||
}
|
||||
if !latent_messages.is_empty() {
|
||||
notice = notice.push(text(format!("{} unfinished message position(s) are retained until their endpoints are complete.", latent_messages.len())).size(12));
|
||||
}
|
||||
notice = notice.push(button("Open selected source").on_press(Message::SourceForSelection));
|
||||
container(notice)
|
||||
.id("scenario-display-notice")
|
||||
.padding(6)
|
||||
.width(Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn spacing_feedback(
|
||||
p: &Project,
|
||||
refusal: Option<&spacing::Refusal>,
|
||||
diagnostics: &[super::super::scenario_layout::Diagnostic],
|
||||
) -> String {
|
||||
if let Some(refusal) = refusal {
|
||||
let reason = match refusal {
|
||||
spacing::Refusal::Geometry(issues) => issues
|
||||
.first()
|
||||
.map(|issue| geometry_problem(p, issue))
|
||||
.unwrap_or_else(|| "This position cannot be displayed.".into()),
|
||||
spacing::Refusal::Command(error) => error.clone(),
|
||||
_ => "This position is unavailable in the current view.".into(),
|
||||
};
|
||||
return format!(
|
||||
"Position unavailable · {reason} Move back to a supported position, or release to cancel."
|
||||
);
|
||||
}
|
||||
let explanation = match diagnostics.first() {
|
||||
Some(super::super::scenario_layout::Diagnostic::SourceOrderCrossing {
|
||||
before,
|
||||
after,
|
||||
..
|
||||
}) => format!(
|
||||
" '{}' is below '{}'; Scenario order stays unchanged.",
|
||||
p.named(*before),
|
||||
p.named(*after)
|
||||
),
|
||||
Some(super::super::scenario_layout::Diagnostic::FrameExtendsPastFollowingAnchor {
|
||||
frame,
|
||||
following,
|
||||
}) => format!(" '{}' overlaps '{}'.", p.named(*frame), p.named(*following)),
|
||||
None => " Scenario order stays unchanged.".into(),
|
||||
};
|
||||
format!("Spacing preview · Release to finish; Escape cancels.{explanation}")
|
||||
}
|
||||
|
||||
fn geometry_problem(p: &Project, issue: &super::super::scenario_layout::GeometryIssue) -> String {
|
||||
use super::super::scenario_layout::GeometryIssue;
|
||||
match issue {
|
||||
GeometryIssue::TypedActivationCrossingNotSupported { call, event } => format!(
|
||||
"The position of '{}' conflicts with the execution of '{}'.",
|
||||
p.named(*event),
|
||||
p.named(*call)
|
||||
),
|
||||
GeometryIssue::CoincidentDistinctEndpoints(message) => format!(
|
||||
"The distinct endpoints of '{}' have the same horizontal position.",
|
||||
p.named(*message)
|
||||
),
|
||||
GeometryIssue::TypedActivationOutsideLifetime { call, .. }
|
||||
| GeometryIssue::TypedActivationOutsideScope { call, .. } => format!(
|
||||
"The position of '{}' falls outside its supported execution interval.",
|
||||
p.named(*call)
|
||||
),
|
||||
GeometryIssue::FixedFrameHeaderWouldMove { frame } => format!(
|
||||
"The saved spacing would move the fixed header of '{}'.",
|
||||
p.named(*frame)
|
||||
),
|
||||
GeometryIssue::OperandScopeOutsideEnclosingInterval { operand, .. } => format!(
|
||||
"The spacing of '{}' extends outside its enclosing frame.",
|
||||
p.named(*operand)
|
||||
),
|
||||
GeometryIssue::LifecycleGeometryNotSupported { .. }
|
||||
| GeometryIssue::LifecycleAnchorOutsideRenderScope { .. }
|
||||
| GeometryIssue::LifetimeInversionNotSupported { .. } => {
|
||||
"The saved spacing cannot preserve the participant lifetime geometry.".into()
|
||||
}
|
||||
_ => "These saved positions are outside the geometry currently supported by the canvas."
|
||||
.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn sequence_action<'a>(
|
||||
title: &str,
|
||||
message: Message,
|
||||
@@ -597,6 +896,12 @@ fn editor_panel<'a, R: iced::advanced::text::Renderer + 'a>(
|
||||
#[cfg(all(test, debug_assertions))]
|
||||
mod tests;
|
||||
|
||||
#[cfg(all(test, debug_assertions))]
|
||||
mod spacing_tests;
|
||||
|
||||
#[cfg(all(test, debug_assertions))]
|
||||
mod details_tests;
|
||||
|
||||
fn sequence_rows<'a>(
|
||||
p: &'a Project,
|
||||
context: Context,
|
||||
|
||||
Reference in New Issue
Block a user