Compact diagram instructions and add contextual help overlay
This commit is contained in:
@@ -6,6 +6,8 @@ mod architecture_authoring_tests;
|
||||
mod behavior_removal_tests;
|
||||
mod checkpoints;
|
||||
#[cfg(test)]
|
||||
mod context_help_tests;
|
||||
#[cfg(test)]
|
||||
mod diagram_focus_tests;
|
||||
pub(crate) mod diagram_removal;
|
||||
#[cfg(test)]
|
||||
@@ -376,6 +378,9 @@ pub enum Message {
|
||||
ExactStrings(bool),
|
||||
Page(Page),
|
||||
Guide(crate::guide::Event),
|
||||
ContextHelp(crate::guide::Topic),
|
||||
CloseContextHelp,
|
||||
Help,
|
||||
Traceability(crate::views::traceability::Event),
|
||||
ReviewLayerTraces,
|
||||
Behavior(crate::behavior::Event),
|
||||
@@ -521,6 +526,7 @@ pub struct App {
|
||||
/// Home resumes this page; explicit Architecture navigation remains separate.
|
||||
last_workspace_page: Page,
|
||||
pub guide: crate::guide::State,
|
||||
pub context_help: bool,
|
||||
pub traceability: crate::views::traceability::State,
|
||||
pub behavior: crate::behavior::State,
|
||||
pub data_state: crate::data_view::State,
|
||||
@@ -643,6 +649,7 @@ impl App {
|
||||
page: Page::Diagram,
|
||||
last_workspace_page: Page::Diagram,
|
||||
guide: crate::guide::State::default(),
|
||||
context_help: false,
|
||||
traceability: Default::default(),
|
||||
behavior: Default::default(),
|
||||
data_state: Default::default(),
|
||||
@@ -1320,7 +1327,7 @@ impl App {
|
||||
return Some(Message::Escape);
|
||||
}
|
||||
if key == keyboard::Key::Named(keyboard::key::Named::F1) {
|
||||
return Some(Message::Page(Page::Guide));
|
||||
return Some(Message::Help);
|
||||
}
|
||||
if key == keyboard::Key::Named(keyboard::key::Named::F3) {
|
||||
return Some(Message::SourceNavigation(
|
||||
@@ -1348,6 +1355,38 @@ impl App {
|
||||
])
|
||||
}
|
||||
pub fn update(&mut self, message: Message) -> Task<Message> {
|
||||
// Help owns user input before any canvas/reference fast path can act.
|
||||
// Passive results may finish; they retain their normal lifetime checks.
|
||||
if self.context_help
|
||||
&& !matches!(
|
||||
message,
|
||||
Message::Guide(_)
|
||||
| Message::CloseContextHelp
|
||||
| Message::Escape
|
||||
| Message::Request(Pending::Close(_))
|
||||
| Message::CheckpointWritten(..)
|
||||
| Message::CheckpointTick
|
||||
| Message::CheckpointLoaded(..)
|
||||
| Message::WindowResized(_)
|
||||
| Message::CanvasResized(_)
|
||||
| Message::DiagramRevealViewport(..)
|
||||
| Message::SourceViewport(_)
|
||||
| Message::Lint(_)
|
||||
| Message::Linted(..)
|
||||
| Message::Saved(_)
|
||||
| Message::Hosted(crate::hosted::Event::Completed(..))
|
||||
| Message::Loaded(_)
|
||||
| Message::IncludesLoaded(..)
|
||||
| Message::SavePath(_)
|
||||
| Message::SourceRecovered(_)
|
||||
| Message::Exported(_)
|
||||
| Message::SourceSymbolsReady(_)
|
||||
| Message::SourceRenameReady(_)
|
||||
| Message::DiagramUsagesReady(_)
|
||||
)
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
let preview_read_only = self.process_preview_primary().is_some();
|
||||
let selection_boundary = self.multiselect_boundary();
|
||||
// Reject delayed gestures before they can retire newer input ownership.
|
||||
@@ -1900,6 +1939,10 @@ impl App {
|
||||
message,
|
||||
Message::Escape | Message::Request(Pending::Close(_))
|
||||
) {
|
||||
if self.context_help {
|
||||
self.context_help = false;
|
||||
return Task::none();
|
||||
}
|
||||
if self.hosted.visible {
|
||||
self.hosted.visible = false;
|
||||
return Task::none();
|
||||
@@ -2091,6 +2134,7 @@ impl App {
|
||||
}
|
||||
pub(crate) fn scenario_focus_owner(&self) -> Option<crate::behavior::scenario::focus::Stamp> {
|
||||
if self.busy
|
||||
|| self.context_help
|
||||
|| self.hosted.visible
|
||||
|| self.workflow.is_some()
|
||||
|| self.architecture.is_some()
|
||||
@@ -2787,6 +2831,34 @@ impl App {
|
||||
);
|
||||
}
|
||||
}
|
||||
Message::ContextHelp(topic) => {
|
||||
self.guide.update(crate::guide::Event::Topic(topic));
|
||||
self.context_help = true;
|
||||
return iced::widget::operation::snap_to(
|
||||
crate::guide::ARTICLE_SCROLL_ID,
|
||||
iced::widget::scrollable::RelativeOffset::START,
|
||||
);
|
||||
}
|
||||
Message::CloseContextHelp => {
|
||||
self.context_help = false;
|
||||
}
|
||||
Message::Help => {
|
||||
let topic = match self.page {
|
||||
Page::Diagram => Some(crate::guide::Topic::Nesting),
|
||||
Page::Behavior => self.behavior.active_owner.and_then(|id| {
|
||||
self.project()
|
||||
.elements
|
||||
.get(&id)
|
||||
.map(|element| match element.kind() {
|
||||
Kind::StateMachine => crate::guide::Topic::States,
|
||||
Kind::Scenario => crate::guide::Topic::Scenarios,
|
||||
_ => crate::guide::Topic::Chains,
|
||||
})
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
return self.update(topic.map_or(Message::Page(Page::Guide), Message::ContextHelp));
|
||||
}
|
||||
Message::ExactStrings(enabled) => {
|
||||
if let Some(d) = &mut self.draft {
|
||||
match d.set_exact(enabled) {
|
||||
|
||||
@@ -635,6 +635,7 @@ impl App {
|
||||
}
|
||||
pub(crate) fn allocation_input_owner(&self) -> bool {
|
||||
self.page == Page::Diagram
|
||||
&& !self.context_help
|
||||
&& self
|
||||
.active_diagram
|
||||
.and_then(|id| self.project().diagrams.get(&id))
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
//! Context help is a reading overlay, not a workspace navigation or edit.
|
||||
use super::*;
|
||||
use crate::diagram_usage::Target;
|
||||
use crate::guide::Topic;
|
||||
|
||||
fn send(app: &mut App, message: Message) {
|
||||
// Do not poll I/O tasks: exercise the same admission boundary as the UI.
|
||||
let _ = app.update(message);
|
||||
}
|
||||
|
||||
fn architecture() -> (App, Id, Id, Id) {
|
||||
let mut project = Project::empty();
|
||||
let a = project
|
||||
.add_element(Kind::Function, Layer::Sa, "Send", None)
|
||||
.unwrap();
|
||||
let b = project
|
||||
.add_element(Kind::Function, Layer::Sa, "Receive", None)
|
||||
.unwrap();
|
||||
let diagram = project.ensure_diagram(Layer::Sa);
|
||||
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::FunctionalDataflow;
|
||||
let a = project.place(diagram, a, Position { x: 20., y: 20. });
|
||||
let b = project.place(diagram, b, Position { x: 400., y: 20. });
|
||||
let mut app = App::with_project(project);
|
||||
app.page = Page::Diagram;
|
||||
app.layer = Layer::Sa;
|
||||
app.active_diagram = Some(diagram);
|
||||
app.selected = diagram;
|
||||
app.refresh_draft();
|
||||
app.reconcile_multiselect();
|
||||
(app, diagram, a, b)
|
||||
}
|
||||
|
||||
fn choose(app: &mut App, placement: Id, choice: multiselect::Choice) {
|
||||
let context = app.multiselect_context().unwrap();
|
||||
send(
|
||||
app,
|
||||
Message::DiagramSelection(multiselect::Event::Choose(
|
||||
context,
|
||||
Target::Placement(placement),
|
||||
choice,
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_help_preserves_group_property_draft_camera_and_model() {
|
||||
let (mut app, diagram, a, b) = architecture();
|
||||
choose(&mut app, a, multiselect::Choice::Replace);
|
||||
choose(&mut app, b, multiselect::Choice::Toggle);
|
||||
send(
|
||||
&mut app,
|
||||
Message::Field("/name".into(), "Uncommitted name 雨".into()),
|
||||
);
|
||||
assert!(app.draft_dirty());
|
||||
app.zoom = 0.73;
|
||||
app.pan = iced::Vector::new(31., -57.);
|
||||
let selected = app.selected;
|
||||
let selection = app.multiselect_snapshot().unwrap().targets().to_vec();
|
||||
assert_eq!(selection.len(), 2);
|
||||
let draft = serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap();
|
||||
let before = app.document.snapshot();
|
||||
let camera = (app.zoom, app.pan);
|
||||
|
||||
send(&mut app, Message::ContextHelp(Topic::Nesting));
|
||||
assert!(app.context_help);
|
||||
for message in [
|
||||
Message::Page(Page::Catalog),
|
||||
Message::Select(diagram),
|
||||
Message::Apply,
|
||||
Message::RevertProperties,
|
||||
Message::Undo,
|
||||
Message::Delete,
|
||||
Message::Field("/name".into(), "Background mutation".into()),
|
||||
] {
|
||||
send(&mut app, message);
|
||||
}
|
||||
assert!(app.context_help);
|
||||
send(
|
||||
&mut app,
|
||||
Message::Guide(crate::guide::Event::Topic(Topic::Exchanges)),
|
||||
);
|
||||
assert_eq!(app.guide.topic, Topic::Exchanges);
|
||||
send(&mut app, Message::CloseContextHelp);
|
||||
|
||||
assert!(!app.context_help);
|
||||
assert_eq!(app.page, Page::Diagram);
|
||||
assert_eq!(app.active_diagram, Some(diagram));
|
||||
assert_eq!(app.selected, selected);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
selection.as_slice()
|
||||
);
|
||||
assert_eq!((app.zoom, app.pan), camera);
|
||||
assert_eq!(
|
||||
serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
|
||||
draft
|
||||
);
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_help_escape_preserves_pending_behavior_form_and_blocks_submit() {
|
||||
use crate::behavior::states;
|
||||
let mut project = Project::empty();
|
||||
let system = project
|
||||
.add_element(Kind::System, Layer::Sa, "System", None)
|
||||
.unwrap();
|
||||
let machine = project
|
||||
.add_element(Kind::StateMachine, Layer::Sa, "Modes", Some(system))
|
||||
.unwrap();
|
||||
let region = project
|
||||
.add_element(Kind::Region, Layer::Sa, "Main", Some(machine))
|
||||
.unwrap();
|
||||
let state = project
|
||||
.add_element(Kind::State, Layer::Sa, "Ready", Some(region))
|
||||
.unwrap();
|
||||
let mut app = App::with_project(project);
|
||||
send(&mut app, Message::Select(state));
|
||||
send(&mut app, Message::Page(Page::Behavior));
|
||||
send(
|
||||
&mut app,
|
||||
Message::Behavior(behavior::Event::States(states::Event::Start(
|
||||
machine,
|
||||
states::Tool::Transition,
|
||||
state,
|
||||
))),
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Message::Behavior(behavior::Event::States(states::Event::Name(
|
||||
"Pending transition 雨".into(),
|
||||
))),
|
||||
);
|
||||
assert!(app.behavior.has_pending());
|
||||
let draft = app.behavior.recovery_snapshot().unwrap();
|
||||
let before = app.document.snapshot();
|
||||
let selected = app.selected;
|
||||
send(&mut app, Message::Help);
|
||||
assert!(app.context_help);
|
||||
assert_eq!(app.guide.topic, Topic::States);
|
||||
for event in [
|
||||
states::Event::Cancel,
|
||||
states::Event::Submit,
|
||||
states::Event::Name("Lost".into()),
|
||||
] {
|
||||
send(&mut app, Message::Behavior(behavior::Event::States(event)));
|
||||
}
|
||||
send(&mut app, Message::Page(Page::Catalog));
|
||||
send(&mut app, Message::Escape);
|
||||
assert!(!app.context_help);
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
assert_eq!(app.selected, selected);
|
||||
assert_eq!(app.behavior.active_owner, Some(machine));
|
||||
assert!(app.behavior.has_pending());
|
||||
assert_eq!(app.behavior.recovery_snapshot().unwrap(), draft);
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_help_retains_source_draft_and_rejects_background_source_input() {
|
||||
let (mut app, diagram, _, _) = architecture();
|
||||
send(&mut app, Message::Page(Page::Text));
|
||||
send(
|
||||
&mut app,
|
||||
Message::SourceEdit(text_editor::Action::SelectAll),
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Message::SourceEdit(text_editor::Action::Edit(text_editor::Edit::Paste(
|
||||
Arc::new("invalid unfinished source 雨".into()),
|
||||
))),
|
||||
);
|
||||
assert!(app.source_dirty());
|
||||
send(&mut app, Message::Page(Page::Diagram));
|
||||
let source = app.source_editor.text();
|
||||
let version = app.source_version;
|
||||
let before = app.document.snapshot();
|
||||
send(&mut app, Message::Help);
|
||||
assert!(app.context_help);
|
||||
assert_eq!(app.guide.topic, Topic::Nesting);
|
||||
for message in [
|
||||
Message::ApplySource,
|
||||
Message::RevertSource,
|
||||
Message::SourceEdit(text_editor::Action::Edit(text_editor::Edit::Paste(
|
||||
Arc::new("overwrite".into()),
|
||||
))),
|
||||
] {
|
||||
send(&mut app, message);
|
||||
}
|
||||
send(&mut app, Message::Escape);
|
||||
assert_eq!(app.page, Page::Diagram);
|
||||
assert_eq!(app.active_diagram, Some(diagram));
|
||||
assert!(app.source_dirty());
|
||||
assert_eq!(app.source_editor.text(), source);
|
||||
assert_eq!(app.source_version, version);
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_help_f1_selects_scenario_topic_without_leaving_the_workspace() {
|
||||
let fixture = behavior::scenario::direct_tests::fixture();
|
||||
let mut app = App::with_project(fixture.project);
|
||||
send(&mut app, Message::Select(fixture.owner));
|
||||
send(&mut app, Message::Page(Page::Behavior));
|
||||
let before = app.document.snapshot();
|
||||
send(&mut app, Message::Help);
|
||||
assert!(app.context_help);
|
||||
assert_eq!(app.guide.topic, Topic::Scenarios);
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
assert!(app.scenario_focus_owner().is_none());
|
||||
assert!(!behavior::canvas_workspace::keyboard_owner_enabled(&app));
|
||||
send(&mut app, Message::Escape);
|
||||
assert!(!app.context_help);
|
||||
assert_eq!(app.selected, fixture.owner);
|
||||
assert_eq!(app.behavior.active_owner, Some(fixture.owner));
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_help_disables_allocation_input_including_early_work_drag_dispatch() {
|
||||
let mut project = Project::empty();
|
||||
project.ensure_diagram(Layer::Oa);
|
||||
let mut app = App::with_project(project);
|
||||
assert!(app.allocation_input_owner());
|
||||
send(&mut app, Message::Help);
|
||||
assert!(app.context_help);
|
||||
assert!(!app.allocation_input_owner());
|
||||
let before = app.document.snapshot();
|
||||
send(
|
||||
&mut app,
|
||||
Message::WorkDrag(allocation_drag::Event::ArrangeNext),
|
||||
);
|
||||
assert!(!app.allocation_drag.arrange_next);
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
send(&mut app, Message::Escape);
|
||||
assert!(app.allocation_input_owner());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_help_rejects_a_reference_picker_open_queued_from_the_inspector() {
|
||||
let mut project = Project::empty();
|
||||
let owner = project
|
||||
.add_element(Kind::DataType, Layer::Oa, "Record", None)
|
||||
.unwrap();
|
||||
if let Some(SharedBody::DataType { definition, .. }) =
|
||||
project.elements.get_mut(&owner).unwrap().shared_mut()
|
||||
{
|
||||
*definition = DataTypeDefinition::Record { supertype: None };
|
||||
}
|
||||
let mut app = App::with_project(project);
|
||||
send(&mut app, Message::Select(owner));
|
||||
let request = app.draft.as_ref().unwrap().reference_open(
|
||||
app.project(),
|
||||
"/body/Oa/Shared/DataType/definition/Record/supertype",
|
||||
iced::Rectangle {
|
||||
x: 790.,
|
||||
y: 400.,
|
||||
width: 278.,
|
||||
height: 32.,
|
||||
},
|
||||
);
|
||||
let before = serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap();
|
||||
send(&mut app, Message::ContextHelp(Topic::Nesting));
|
||||
assert!(app.context_help);
|
||||
send(&mut app, Message::FieldReferenceOpen(request));
|
||||
assert!(!app.reference_input_active());
|
||||
send(&mut app, Message::Escape);
|
||||
assert!(!app.context_help);
|
||||
assert_eq!(
|
||||
serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
|
||||
before
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,8 @@ pub(crate) fn root<'a, R: renderer::Renderer + 'a>(
|
||||
content: Element<'a, Message, Theme, R>,
|
||||
) -> Element<'a, Message, Theme, R> {
|
||||
let modal = app.text_workspace.menu().is_none()
|
||||
&& (app.behavior.creation.has_pending()
|
||||
&& (app.context_help
|
||||
|| app.behavior.creation.has_pending()
|
||||
|| app.diagram_usages.is_some()
|
||||
|| app.behavior.source_active()
|
||||
|| (app.page == Page::Text
|
||||
|
||||
@@ -16,6 +16,7 @@ pub fn keyboard_enabled(app: &crate::app::App) -> bool {
|
||||
/// Passive I/O suspends commands without changing the logical input owner.
|
||||
pub(crate) fn keyboard_owner_enabled(app: &crate::app::App) -> bool {
|
||||
!app.hosted.visible
|
||||
&& !app.context_help
|
||||
&& app.workflow.is_none()
|
||||
&& app.file_prompt.is_none()
|
||||
&& !app.delete_pending
|
||||
|
||||
@@ -225,13 +225,13 @@ fn selection_controls(app: &App) -> Element<'_, Message> {
|
||||
}
|
||||
controls = controls
|
||||
.push(action(
|
||||
"Clear selection",
|
||||
"Clear",
|
||||
Message::DiagramSelection(SelectionEvent::Clear(snapshot.context())),
|
||||
))
|
||||
.push(
|
||||
label(format!(
|
||||
"Properties edit only {}",
|
||||
crate::views::short(app.project().named(app.selected), 18)
|
||||
"Properties: {} only",
|
||||
crate::views::short(app.project().named(app.selected), 10)
|
||||
))
|
||||
.wrapping(iced::widget::text::Wrapping::None),
|
||||
);
|
||||
@@ -241,12 +241,12 @@ fn selection_controls(app: &App) -> Element<'_, Message> {
|
||||
} else {
|
||||
"Use Select / move to select multiple elements"
|
||||
};
|
||||
column![
|
||||
controls,
|
||||
label(hint).wrapping(iced::widget::text::Wrapping::None)
|
||||
]
|
||||
.height(56)
|
||||
.into()
|
||||
controls
|
||||
.push(crate::views::diagram_help(
|
||||
crate::guide::Topic::States,
|
||||
hint,
|
||||
))
|
||||
.into()
|
||||
}
|
||||
|
||||
fn transition_details(machine: Id, selected: Id) -> Element<'static, Message> {
|
||||
|
||||
@@ -585,15 +585,13 @@ fn selection_controls(app: &App) -> Element<'_, Message> {
|
||||
.wrapping(iced::widget::text::Wrapping::None),
|
||||
);
|
||||
}
|
||||
// These two rows exist even with an empty set so selection never moves the
|
||||
// canvas viewport or changes the camera's interpretation of the pointer.
|
||||
column![
|
||||
controls,
|
||||
label("Shift+click toggles · drag selected cards to reorder · previews are read-only")
|
||||
.wrapping(iced::widget::text::Wrapping::None)
|
||||
]
|
||||
.height(56)
|
||||
.into()
|
||||
// Keep the row even with an empty set so selection never moves the canvas.
|
||||
controls
|
||||
.push(crate::views::diagram_help(
|
||||
crate::guide::Topic::Chains,
|
||||
"Shift-click toggles selection. Drag selected local cards to reorder; reused previews are read-only.",
|
||||
))
|
||||
.into()
|
||||
}
|
||||
|
||||
fn editor_panel<'a, R: iced::advanced::text::Renderer + 'a>(
|
||||
|
||||
@@ -463,6 +463,10 @@ fn article(topic: Topic) -> Article {
|
||||
summary: "An operational Process or system Functional chain connects contributions of existing work and exchanges, including alternatives and reusable procedures. It explains how work contributes to an outcome.",
|
||||
keywords: "process functional chain ordered occurrence sequence path involvement dataflow exchange repeated steps caller contextual qualified assembly common work focus reuse",
|
||||
sections: &[
|
||||
explain(
|
||||
"Select a group",
|
||||
"Use Select, then Shift-click to add or remove contributions from the selection. Drag selected local cards to reorder them in the same column. Reused previews are read-only; open their referenced definition to edit. Properties affect only the primary selected item. Help or F1 opens this guide without leaving the diagram; Close or Escape returns to editing.",
|
||||
),
|
||||
walkthrough(
|
||||
"Describe a path through exchanges",
|
||||
&[
|
||||
@@ -569,6 +573,10 @@ fn article(topic: Topic) -> Article {
|
||||
summary: "Describe the conditions in which the operation or system behaves differently. States and modes belong to regions within a state machine.",
|
||||
keywords: "state machine mode region initial final transition trigger guard effect entry exit do availability degraded nominal diagram draw reconnect local internal timed fold unfold placement pan zoom undo",
|
||||
sections: &[
|
||||
explain(
|
||||
"Select a group",
|
||||
"Choose Select / move, then Shift-click to add or remove elements from the selection. Properties affect only the primary selected item; Hide selected removes placements from this view. Help or F1 opens this guide without leaving the diagram; Close or Escape returns to editing.",
|
||||
),
|
||||
walkthrough(
|
||||
"Build a state machine",
|
||||
&[
|
||||
@@ -1156,6 +1164,14 @@ fn topic_button(topic: Topic, selected: bool) -> iced::widget::Button<'static, M
|
||||
}
|
||||
|
||||
fn article_view(topic: Topic) -> Element<'static, Message> {
|
||||
article_content(topic, true)
|
||||
}
|
||||
|
||||
pub(crate) fn context_view(state: &State) -> Element<'static, Message> {
|
||||
article_content(state.topic, false)
|
||||
}
|
||||
|
||||
fn article_content(topic: Topic, navigation: bool) -> Element<'static, Message> {
|
||||
let article = article(topic);
|
||||
let mut content = column![
|
||||
text(topic.category()).size(11).color(style::MUTED),
|
||||
@@ -1194,14 +1210,14 @@ fn article_view(topic: Topic) -> Element<'static, Message> {
|
||||
}
|
||||
content = content.push(container(body).padding(20).width(Fill).style(style::card));
|
||||
}
|
||||
if let Some((page, label)) = article.destination {
|
||||
if navigation && let Some((page, label)) = article.destination {
|
||||
content = content.push(
|
||||
button(text(label).size(13))
|
||||
.padding([11, 15])
|
||||
.on_press(Message::Page(page)),
|
||||
);
|
||||
}
|
||||
if topic == Topic::FirstProject {
|
||||
if navigation && topic == Topic::FirstProject {
|
||||
content = content.push(text("For a worked model, open the water-pressure walkthrough. Copy the whole examples/release1 folder and open the copied project.arcadia before editing; read its README.md. If you use Save as, also copy evidence/ beside the new entry: Save as copies model source, not external artifact targets.").size(13));
|
||||
content = content.push(
|
||||
button(text("Open water-pressure walkthrough").size(13))
|
||||
|
||||
+130
-92
@@ -25,6 +25,23 @@ pub fn action<'a>(s: &str, m: Message) -> widget::Button<'a, Message> {
|
||||
.on_press(m)
|
||||
.style(style::nav)
|
||||
}
|
||||
|
||||
/// Optional instructions belong beside the work, without taking a canvas row.
|
||||
/// The button also opens keyboard-accessible help; the tooltip is only a preview.
|
||||
pub(crate) fn diagram_help<'a>(
|
||||
topic: crate::guide::Topic,
|
||||
hint: impl Into<String>,
|
||||
) -> View<'a, Message> {
|
||||
tooltip(
|
||||
action("Help · F1", Message::ContextHelp(topic)),
|
||||
container(text(hint.into()).size(12))
|
||||
.padding(10)
|
||||
.max_width(360)
|
||||
.style(style::card),
|
||||
tooltip::Position::Bottom,
|
||||
)
|
||||
.into()
|
||||
}
|
||||
pub fn short(s: &str, limit: usize) -> String {
|
||||
let s = s.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if s.graphemes(true).count() > limit {
|
||||
@@ -240,11 +257,23 @@ pub fn view(app: &App) -> View<'_, Message> {
|
||||
if !matches!(app.page, Page::Home | Page::Text | Page::Guide) {
|
||||
body = body.push(inspector(app));
|
||||
}
|
||||
let diagram_feedback = diagram_feedback(app);
|
||||
// Keep operation outcomes visible even while a drawing hint is active.
|
||||
let (status, status_detail) = if app.busy {
|
||||
("Working…".into(), "Working…".into())
|
||||
} else if let Some(feedback) = diagram_feedback.filter(|feedback| *feedback != app.status) {
|
||||
(
|
||||
format!("{} · {}", short(&app.status, 60), short(&feedback, 80)),
|
||||
format!("{}\n{}", app.status, feedback),
|
||||
)
|
||||
} else {
|
||||
(short(&app.status, 145), app.status.clone())
|
||||
};
|
||||
let footer = container(
|
||||
row![
|
||||
container(tooltip(
|
||||
text(short(if app.busy { "Working…" } else { &app.status }, 145)).size(11),
|
||||
container(text(if app.busy { "Working…" } else { &app.status }).size(12))
|
||||
text(status).size(11).wrapping(widget::text::Wrapping::None),
|
||||
container(text(status_detail).size(12))
|
||||
.padding(10)
|
||||
.max_width(600)
|
||||
.style(style::card),
|
||||
@@ -267,6 +296,24 @@ pub fn view(app: &App) -> View<'_, Message> {
|
||||
.padding([4, 16])
|
||||
.style(style::panel);
|
||||
let root = crate::allocation_input::root(app, column![header, task_nav, body, footer]);
|
||||
if app.context_help {
|
||||
return modal_width(
|
||||
root,
|
||||
column![
|
||||
row![
|
||||
text("Diagram help").size(18),
|
||||
Space::new().width(Fill),
|
||||
action("Close · Esc", Message::CloseContextHelp),
|
||||
]
|
||||
.align_y(iced::Center),
|
||||
crate::guide::context_view(&app.guide),
|
||||
]
|
||||
.spacing(12)
|
||||
.height((app.window_size.height - 140.).max(300.))
|
||||
.into(),
|
||||
(app.window_size.width - 80.).min(900.),
|
||||
);
|
||||
}
|
||||
if app.architecture.as_ref().is_some_and(|s| s.modal()) {
|
||||
return modal_width(
|
||||
root,
|
||||
@@ -362,7 +409,8 @@ pub fn view(app: &App) -> View<'_, Message> {
|
||||
(app.window_size.width - 32.).clamp(120., 760.),
|
||||
);
|
||||
}
|
||||
root
|
||||
// Match modal_width's first child so help does not recreate the editor tree.
|
||||
widget::stack![root].into()
|
||||
}
|
||||
fn modal<'a>(base: View<'a, Message>, content: View<'a, Message>) -> View<'a, Message> {
|
||||
modal_width(base, content, 800.)
|
||||
@@ -462,7 +510,15 @@ fn diagram(app: &App) -> View<'_, Message> {
|
||||
Message::Projection
|
||||
)
|
||||
.padding(8)
|
||||
.text_size(12)
|
||||
.text_size(12),
|
||||
diagram_help(
|
||||
crate::guide::Topic::Nesting,
|
||||
if projection == Some(ProjectionKind::Traceability) {
|
||||
"Shows OA and SA elements with correspondence lines. Place both ends of a trace to show it; port traces use their owner cards. Open Help for trace visibility and editing controls."
|
||||
} else {
|
||||
"Shift-click to add or remove selection. Right-click for actions; drag to arrange; scroll to zoom. Drag a connection line, bend or label to move it; Insert adds a bend and Escape cancels. Open Help for full editing controls."
|
||||
},
|
||||
)
|
||||
]
|
||||
.spacing(10)
|
||||
.align_y(iced::Center);
|
||||
@@ -568,31 +624,30 @@ fn diagram(app: &App) -> View<'_, Message> {
|
||||
.feedback
|
||||
.as_ref()
|
||||
.map(|feedback| feedback.destination_identity())
|
||||
.unwrap_or_else(|| "None · drag work to choose".into());
|
||||
.unwrap_or_else(|| "—".into());
|
||||
let inspection = app
|
||||
.allocation_drag
|
||||
.feedback
|
||||
.as_ref()
|
||||
.map(|feedback| feedback.inspection_identity())
|
||||
.unwrap_or_else(|| destination.clone());
|
||||
tools = tools.push(
|
||||
container(tooltip(
|
||||
text(format!("Destination: {}", short(&destination, 28)))
|
||||
.size(13)
|
||||
.wrapping(widget::text::Wrapping::None),
|
||||
container(text(inspection).size(13))
|
||||
.padding(10)
|
||||
.max_width(420)
|
||||
.style(style::card),
|
||||
tooltip::Position::Top,
|
||||
))
|
||||
.id("allocation-destination")
|
||||
.height(26)
|
||||
.width(Fill)
|
||||
.clip(true),
|
||||
);
|
||||
let destination_control = container(tooltip(
|
||||
text(format!("Destination: {}", short(&destination, 28)))
|
||||
.size(13)
|
||||
.wrapping(widget::text::Wrapping::None),
|
||||
container(text(inspection).size(13))
|
||||
.padding(10)
|
||||
.max_width(420)
|
||||
.style(style::card),
|
||||
tooltip::Position::Top,
|
||||
))
|
||||
.id("allocation-destination")
|
||||
.height(26)
|
||||
.width(Fill)
|
||||
.clip(true);
|
||||
tools = tools.push(
|
||||
row![
|
||||
destination_control,
|
||||
action(
|
||||
&format!(
|
||||
"{} · {}",
|
||||
@@ -604,8 +659,7 @@ fn diagram(app: &App) -> View<'_, Message> {
|
||||
crate::allocation_input::ARRANGE_SHORTCUT
|
||||
),
|
||||
Message::WorkDrag(crate::app::allocation_drag::Event::ArrangeNext)
|
||||
),
|
||||
label("Drag work into a performer to assign · out to remove assignment")
|
||||
)
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::Center),
|
||||
@@ -616,34 +670,22 @@ fn diagram(app: &App) -> View<'_, Message> {
|
||||
if projection == Some(ProjectionKind::Traceability) {
|
||||
let correspondence_lines = view.edges.iter().filter(|edge| edge.trace).count();
|
||||
tools = tools.push(
|
||||
column![
|
||||
label("Shows OA and SA elements together, with correspondence lines alongside ordinary relationships."),
|
||||
label("Place both ends of a trace to show its line. Port traces use their owner cards; traces between relationships are reviewed on the Traceability page."),
|
||||
row![
|
||||
label(format!("{correspondence_lines} correspondence lines in this view")),
|
||||
action(
|
||||
"Add existing elements…",
|
||||
Message::Architecture(crate::app::architecture_authoring::Event::Existing(None))
|
||||
),
|
||||
action("Review layer traces", Message::ReviewLayerTraces),
|
||||
action("View visibility…", Message::Select(diagram)),
|
||||
]
|
||||
.spacing(7)
|
||||
.align_y(iced::Center)
|
||||
.wrap(),
|
||||
row![
|
||||
label(format!("{correspondence_lines} trace lines")),
|
||||
action(
|
||||
"Add existing elements…",
|
||||
Message::Architecture(crate::app::architecture_authoring::Event::Existing(
|
||||
None
|
||||
))
|
||||
),
|
||||
action("Review layer traces", Message::ReviewLayerTraces),
|
||||
action("View visibility…", Message::Select(diagram)),
|
||||
]
|
||||
.spacing(4),
|
||||
.spacing(7)
|
||||
.align_y(iced::Center)
|
||||
.wrap(),
|
||||
);
|
||||
}
|
||||
let edge = view.edges.iter().find(|edge| edge.id == app.selected);
|
||||
let promoted = edge.is_some_and(|edge| {
|
||||
oa_core::projection::edge_endpoint_placements(
|
||||
app.project(),
|
||||
&app.project().diagrams[&diagram],
|
||||
edge.id,
|
||||
)
|
||||
.is_some_and(|(source, target)| source != edge.source || target != edge.target)
|
||||
});
|
||||
let selection = app.multiselect_snapshot();
|
||||
let count = selection.as_ref().map_or(0, |s| s.targets().len());
|
||||
let mut selection_controls =
|
||||
@@ -662,66 +704,62 @@ fn diagram(app: &App) -> View<'_, Message> {
|
||||
}
|
||||
selection_controls = selection_controls
|
||||
.push(action(
|
||||
"Clear selection",
|
||||
"Clear",
|
||||
Message::DiagramSelection(crate::app::multiselect::Event::Clear(
|
||||
snapshot.context(),
|
||||
)),
|
||||
))
|
||||
.push(
|
||||
label(format!(
|
||||
"Properties edit only {}",
|
||||
short(app.project().named(app.selected), 36)
|
||||
"Properties: {} only",
|
||||
short(app.project().named(app.selected), 14)
|
||||
))
|
||||
.wrapping(widget::text::Wrapping::None),
|
||||
);
|
||||
}
|
||||
let hint = if let Some(feedback) = &app.allocation_drag.feedback {
|
||||
feedback.text.as_str()
|
||||
} else if let Some(session) = &app.connection {
|
||||
if session.source.is_some() {
|
||||
if session.candidates.is_empty() {
|
||||
"No compatible targets are visible. Expand containers or show an endpoint · Esc cancels"
|
||||
} else {
|
||||
"Choose a highlighted target · middle-drag or arrow keys pan · Esc cancels"
|
||||
}
|
||||
} else {
|
||||
session.tool.hint()
|
||||
}
|
||||
} else if count > 1 {
|
||||
if selection
|
||||
.as_ref()
|
||||
.is_some_and(|s| crate::app::multiselect::placements(s).is_some())
|
||||
&& projection != Some(ProjectionKind::Allocation)
|
||||
{
|
||||
"Shift-click to add/remove · drag selected to move · right-click for actions · scroll to zoom"
|
||||
} else {
|
||||
"Shift-click to add/remove · right-click for selection actions · Esc clears selection"
|
||||
}
|
||||
} else if promoted {
|
||||
"Expand endpoint containers to edit this connection route"
|
||||
} else if edge.is_some() {
|
||||
"Right-click for routing · drag line / bend / label · Insert adds a bend · Esc cancels"
|
||||
} else {
|
||||
"Shift-click to add/remove · right-click for actions · drag to arrange · scroll to zoom"
|
||||
};
|
||||
// Selection changes must not resize the measured canvas or refit its
|
||||
// camera. Reserve both rows even when no group actions are available.
|
||||
// Selection never moves the canvas. General controls live in Help;
|
||||
// active gesture feedback uses the persistent application status bar.
|
||||
tools = tools.push(
|
||||
container(
|
||||
column![
|
||||
selection_controls,
|
||||
label(hint).wrapping(widget::text::Wrapping::None)
|
||||
]
|
||||
.spacing(3),
|
||||
)
|
||||
.height(56)
|
||||
.width(Fill)
|
||||
.clip(true),
|
||||
container(selection_controls)
|
||||
.height(32)
|
||||
.width(Fill)
|
||||
.clip(true),
|
||||
);
|
||||
}
|
||||
column![container(tools).padding(14), canvas].into()
|
||||
}
|
||||
|
||||
fn diagram_feedback(app: &App) -> Option<String> {
|
||||
if app.page != Page::Diagram {
|
||||
return None;
|
||||
}
|
||||
if let Some(feedback) = &app.allocation_drag.feedback {
|
||||
return Some(feedback.text.clone());
|
||||
}
|
||||
if let Some(session) = &app.connection {
|
||||
return Some(
|
||||
if session.source.is_none() {
|
||||
session.tool.hint()
|
||||
} else if session.candidates.is_empty() {
|
||||
"No compatible targets visible. Expand containers or show an endpoint · Esc cancels"
|
||||
} else {
|
||||
"Choose a highlighted target · Esc cancels"
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
let diagram = app.active_diagram?;
|
||||
let view = oa_core::projection::project_view(app.project(), diagram);
|
||||
let edge = view.edges.iter().find(|edge| edge.id == app.selected)?;
|
||||
let (source, target) = oa_core::projection::edge_endpoint_placements(
|
||||
app.project(),
|
||||
&app.project().diagrams[&diagram],
|
||||
edge.id,
|
||||
)?;
|
||||
(source != edge.source || target != edge.target)
|
||||
.then(|| "Expand endpoint containers to edit this connection route".into())
|
||||
}
|
||||
|
||||
fn inspector(app: &App) -> View<'_, Message> {
|
||||
let p = app.project();
|
||||
if let Some(target) = app.process_preview_primary() {
|
||||
|
||||
Reference in New Issue
Block a user