Compact diagram instructions and add contextual help overlay

This commit is contained in:
2026-09-24 23:06:53 -07:00
parent 715036e132
commit e0812b3932
12 changed files with 566 additions and 114 deletions
+73 -1
View File
@@ -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
);
}
+2 -1
View File
@@ -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
+9 -9
View File
@@ -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>(
+18 -2
View File
@@ -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
View File
@@ -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() {
+2
View File
@@ -2,6 +2,8 @@
The normative contract is [language.rst](language.rst); [scope.md](scope.md) explains deliberate differences from the complete Arcadia/Capella ecosystem. V2 uses one typed model and one validated mutation boundary across the desktop, text editor and REST service.
Desktop changes follow the [editing workspace UX review criteria](ui-review.md) for concise persistent content, contextual help, visible actionable feedback and native interaction review.
## Crate boundaries
| Crate | Responsibility | Does not depend on |
+27
View File
@@ -0,0 +1,27 @@
# Editing workspace UX review
These development criteria implement the [project goal](../PROJECT_GOAL.md): reduce system engineers' cognitive load, keep graphical editing intuitive, and provide concise, navigable built-in help. They also address the user's request tracked in Gitea issue #39 to prevent persistent instructional text from consuming editing space. They are review criteria, not a claim that any particular build has passed them.
## Persistent content and progressive disclosure
Populated editing workspaces prioritize model content, controls, current state and actionable feedback. General explanations, gesture tutorials and repeated shortcut lists belong in contextual Help, tooltips or explicitly opened details. Do not add permanent instructional paragraphs above a populated canvas merely to explain a feature.
Keep essential control labels and relevant status visible. Offer concise next-step guidance in empty states and while a user is performing a multi-step action. Put longer explanations behind a clearly labeled disclosure near the relevant control or context. Essential instructions must be accessible through an explicit keyboard-reachable route; a hover-only tooltip is insufficient. Reuse the built-in guide for broader modeling explanations.
Distinguish instruction from information needed to act safely. Current errors, pending edits, failed operations, fallback rendering, unavailable actions and recovery steps must remain apparent. Preserve consequential distinctions such as editing a shared definition, changing semantic order rather than saved positions, or applying properties to only one member of a selection. Long diagnostic detail may be disclosed on demand, but its visible summary must identify the problem and a usable next step.
## Stable editing context
Selection changes and gesture feedback must not unexpectedly move or resize the canvas, reset its camera, or change the interpretation of the pointer. Compact existing stable layout slots instead of conditionally removing them when their content is absent. Opening and closing help must preserve the model, selection and pending drafts, and return the user to a usable editing context. Explicitly requested panel changes may resize the workspace; verify their effect on focus and navigation.
## Native review
Exercise the changed workspace in the actual built application at its configured minimum window size and a representative normal size. Include an independent reviewer for material interaction or layout changes, following [the working agreement](../AGENTS.md). Review representative populated models as well as relevant empty, pending, failure and recovery states.
- Inspect whether instructional prose or unused space displaces useful model content. Compare before/after screenshots at the same window size, display scaling, model and panel state when assessing space savings.
- Perform the affected workflow: select, edit, navigate, cancel and recover as applicable. Change between empty, single and multiple selections; exercise affected gestures and inspect camera and pointer stability.
- Find and use the disclosed guidance by keyboard as well as pointer. Check focus, dismissal, return to editing, and preservation of drafts.
- Trigger relevant disabled or rejected operations. Confirm their reason and recovery action remain visible, accurate and reachable.
- Check text, controls, panels and warnings for clipping, overlap, excessive scrolling and lost actions at the minimum size. Do not trade recovered space for unreadable text or unexplained symbols.
Record the tested code version, runtime environment, workflows, observations and representative screenshots in the change's review evidence. Screenshots support layout findings; they do not establish working interaction, accessibility or state preservation. Fix material findings and recheck affected behavior before treating the change as verified.
+22
View File
@@ -0,0 +1,22 @@
# Compact diagram controls and contextual help
Branch-specific work for [issue 39](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/39), based on published preview source `715036e132677dd2bc2fb37ed316d93dc9987aec`.
This record does not accept the complete first release or the other diagram defects.
## Problem and choice
The user's screenshot showed two permanent Traceability explanation paragraphs and a separate generic gesture-hint row. Direct Windows inspection of the released water-pressure example reproduced these. At a 1100×700 client size (1102×732 captured window), the Traceability canvas began around window y=470 and ended around y=692, leaving about 220 pixels for modeling. At the normal 1440×900 client size its top was around y=417. The release was opened from the existing downloaded package; only an in-memory projection switch was made and Undo restored the clean model before closure.
The candidate moves general instructions into contextual Help, reusing the offline guide. Architecture, State and Process selection bars use a stable compact row; allocation destination and Arrange controls share a row. Traceability retains its displayed-line count and existing actions. Active drawing/assignment feedback appears beside the operation outcome in the persistent status bar, with full text on hover. Operation errors must never be replaced by generic guidance.
Help opens as an overlay and closes with Close or Escape. F1 opens the relevant diagram topic. The workspace, selected group, pending source/property/behavior drafts and camera remain owned by their existing state. Background user commands are rejected before specialized canvas/reference dispatch. Passive results retain their established validity checks. The editor remains the first child of a stable Stack while Help opens and closes.
Rejected alternatives: ordinary navigation to the Guide page cleared selection and could reject Help while a behavior draft was pending; placing feedback beside the entire selection toolbar exhausted narrow-window width; unconditional status substitution hid operation failures. Independent source review identified these before acceptance. Hover-only help was rejected because the complete instructions need a keyboard-accessible route.
[Editing workspace UX review](../docs/ui-review.md) is the development/review contract requested by the user. It covers persistent prose, actionable feedback, context preservation and independent native interaction checks.
## Verification status
Implementation and six focused state/admission regression tests are prepared. First intermediate `cargo check -p oa-desktop --locked` passed before later overlay and input changes; it is not a check of the final candidate. Current build, focused tests, strict lint, formatting, native comparison and fresh final review remain pending. Baseline captures and raw build logs are local-only until the evidence publication step. No new executable, integrated change, issue closure or release is claimed.
Required native checks: minimum and normal windows; Traceability and Allocation space comparison; multi-selection with long names; Help/F1/Close/Escape with property and behavior drafts; editor focus and canvas scroll/camera restoration; active feedback plus operation failure; State and Process controls; subsequent edit/Undo/Redo and source preservation. Linux remains deferred under the recorded user instruction.